diff --git a/.gitattributes b/.gitattributes index 6f87ca1fe..62ae670ee 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,5 @@ # Real-world log fixtures are byte-sensitive (UTF-8 BOM + CRLF); never normalize them. src-tauri/tests/fixtures/** -text +# SCCM parser fixtures are byte-sensitive evidence; never normalize them. +crates/cmtraceopen-parser/tests/fixtures/** -text diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cad41e44..236f992a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,16 @@ All notable changes to this project will be documented in this file. - **Intune Device Inventory log support (#354)**: CMTrace Open now discovers and parses the complete Microsoft Device Inventory Agent log family under `C:\Program Files\Microsoft Device Inventory Agent\Logs` instead of falling back to the generic timestamped or plain-text readers. Three wire formats are recognized by content: the **harvester** dialect (`IntuneInventoryHarvesterLog.log` and its timestamped rotations), where the producer's bracketed `[Information]`/`[Warning]`/`[Error]` level is authoritative and is no longer left in the message or overridden by keyword inference; the **Inventory Adaptor** dialect (`InventoryAdaptor.log` and the literal trailing-underscore `InventoryAdaptor.log_` rotation), which now keeps its timestamp and process ID and holds continuation JSON in the record that introduced it; and a **rotation-failure** dialect, where a .NET exception and its stack trace stay in one logical record. The Device Inventory Agent folder is available from the known-sources menu, folder aggregation includes `.log`, timestamped rotations, and `.log_`, and Windows file association covers the producer's literal `.log_` extension. Real-time tailing preserves logical records across appends. +### Fixed + +- **SCCM server coverage topology (#335)**: Normalized server coverage rows now + retain optional opaque producer-host and workflow-subject handles, preventing + artifacts from distinct physical producers or workflow subjects from + collapsing into one row. Site-core analysis also rejects coverage whose + topology does not match its artifact membership and emits explicit coverage + gaps instead of shaping results from incongruent input. The additive fields + remain schema v1 and are omitted from JSON when absent. + ## [1.5.0] - 2026-07-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index 75b1e868c..ffecb5a39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -632,6 +632,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2 0.11.0", "thiserror 2.0.19", ] diff --git a/crates/cmtraceopen-parser/Cargo.toml b/crates/cmtraceopen-parser/Cargo.toml index 670bb05c5..192d40cf0 100644 --- a/crates/cmtraceopen-parser/Cargo.toml +++ b/crates/cmtraceopen-parser/Cargo.toml @@ -26,3 +26,6 @@ encoding_rs = "0.8" log = "0.4" thiserror = "2" base64 = "0.22" +sha2 = "0.11" + +[dev-dependencies] diff --git a/crates/cmtraceopen-parser/src/esp/redaction.rs b/crates/cmtraceopen-parser/src/esp/redaction.rs index 340d8bf7e..45f1189f9 100644 --- a/crates/cmtraceopen-parser/src/esp/redaction.rs +++ b/crates/cmtraceopen-parser/src/esp/redaction.rs @@ -1418,8 +1418,7 @@ fn redact_text_for_context(value: &str, context: TextRedactionContext) -> String // the MAC matcher could pick up decimal sub-authority pairs inside it, and // IPv4 runs before IPv6 so an IPv4-mapped IPv6 address cannot leak its dotted // tail. - let redacted = - azure_storage_credential_pattern().replace_all(&redacted, "${prefix}[redacted]"); + let redacted = azure_storage_credential_pattern().replace_all(&redacted, "${prefix}[redacted]"); let redacted = ipv4_address_pattern().replace_all(&redacted, REDACTED); let redacted = mac_address_pattern().replace_all(&redacted, REDACTED); let redacted = redact_ipv6_addresses(&redacted); diff --git a/crates/cmtraceopen-parser/src/esp/reducer.rs b/crates/cmtraceopen-parser/src/esp/reducer.rs index e40a00b9c..a0297fd55 100644 --- a/crates/cmtraceopen-parser/src/esp/reducer.rs +++ b/crates/cmtraceopen-parser/src/esp/reducer.rs @@ -3137,7 +3137,10 @@ fn sidecar_app_state_observation( ordinal: usize, observation: &EspRegistryObservation, ) -> Option { - let field = if observation.value_name.eq_ignore_ascii_case("InstallationState") { + let field = if observation + .value_name + .eq_ignore_ascii_case("InstallationState") + { SidecarAppField::InstallationState } else if observation.value_name.eq_ignore_ascii_case("ErrorHresult") { SidecarAppField::ErrorHresult diff --git a/crates/cmtraceopen-parser/src/esp/timeline.rs b/crates/cmtraceopen-parser/src/esp/timeline.rs index 8d68f019b..41e8763fa 100644 --- a/crates/cmtraceopen-parser/src/esp/timeline.rs +++ b/crates/cmtraceopen-parser/src/esp/timeline.rs @@ -115,7 +115,10 @@ mod tests { // "...05.250Z" before "...05Z" because '.' (0x2E) < 'Z' (0x5A), which // inverts chronology; the parsed-instant key must keep 05 before 05.250. let entries = vec![ - (0usize, timeline_entry("timeline|a|b|0", "2026-07-15T12:00:05Z")), + ( + 0usize, + timeline_entry("timeline|a|b|0", "2026-07-15T12:00:05Z"), + ), ( 1usize, timeline_entry("timeline|a|b|1", "2026-07-15T12:00:05.250Z"), diff --git a/crates/cmtraceopen-parser/src/lib.rs b/crates/cmtraceopen-parser/src/lib.rs index 280fc4244..2815a94fa 100644 --- a/crates/cmtraceopen-parser/src/lib.rs +++ b/crates/cmtraceopen-parser/src/lib.rs @@ -14,4 +14,5 @@ pub mod esp; pub mod intune; pub mod models; pub mod parser; +pub mod sccm; pub(crate) mod wire; diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index 379cece2d..cd50186e0 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -12,8 +12,12 @@ use regex::Regex; use super::severity::detect_severity_from_text; use crate::models::log_entry::{LogEntry, LogFormat, ParserSpecialization, Severity}; +#[cfg(test)] +use std::cell::RefCell; use std::sync::OnceLock; +const CCM_RECORD_OPENER: &str = " &'static Regex { CELL.get_or_init(|| { Regex::new(concat!( r#"[\s\S]*?)\]LOG\]!>"#, - r#"\d{1,2}):(?P\d{1,2}):(?P\d{1,2})\.(?P\d+)(?P[+-]*\d+)""#, + r#"\d{1,2}):(?P\d{1,2}):(?P\d{1,2})\.(?P\d+(?:[+-]\d+)?)""#, r#"\s+date="(?P\d{1,2})-(?P\d{1,2})-(?P\d{4})""#, r#"\s+component="(?P[^"]*)""#, - r#"\s+context="[^"]*""#, + r#"\s+context="(?P[^"]*)""#, r#"\s+type="(?P\d)?""#, r#"\s+thread="(?P\d+)?""#, r#"(?:\s+file="(?P[^"]*)")?>"#, @@ -40,7 +44,8 @@ fn ccm_re() -> &'static Regex { /// Returns None if the line doesn't match the CCM format. fn parse_line(line: &str) -> Option { let caps = ccm_re().captures(line)?; - parse_captures(&caps) + let parsed = parse_captures(&caps)?; + parsed.public_compatible.then_some(parsed) } struct CcmParsed { @@ -53,6 +58,112 @@ struct CcmParsed { thread_display: Option, source_file: Option, timezone_offset: i32, + context: Option, + timestamp_parse: CcmTimestampParse, + public_compatible: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CcmTimestampParseState { + NormalizedUtc, + OffsetMissing, + OffsetInvalid, + TimestampMissing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CcmTimestampParse { + pub original_display: Option, + pub offset_minutes: Option, + pub utc_millis: Option, + pub ordering_state: CcmTimestampParseState, +} + +impl CcmTimestampParse { + fn missing() -> Self { + Self { + original_display: None, + offset_minutes: None, + utc_millis: None, + ordering_state: CcmTimestampParseState::TimestampMissing, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct CcmLogicalRecord { + pub entry: LogEntry, + pub context: Option, + pub line_start: u32, + pub line_end: u32, + pub timestamp: CcmTimestampParse, +} + +impl CcmParsed { + fn into_logical_record( + self, + id: u64, + line_start: u32, + line_end: u32, + file_path: &str, + ) -> CcmLogicalRecord { + CcmLogicalRecord { + entry: LogEntry { + id, + line_number: line_start, + message: self.message, + component: self.component, + timestamp: self.timestamp, + timestamp_display: self.timestamp_display, + severity: self.severity, + thread: Some(self.thread), + thread_display: self.thread_display, + source_file: self.source_file, + format: LogFormat::Ccm, + file_path: file_path.to_string(), + timezone_offset: Some(self.timezone_offset), + error_code_spans: Vec::new(), + ip_address: None, + host_name: None, + mac_address: None, + result_code: None, + gle_code: None, + setup_phase: None, + operation_name: None, + http_method: None, + uri_stem: None, + uri_query: None, + status_code: None, + sub_status: None, + time_taken_ms: None, + client_ip: None, + server_ip: None, + user_agent: None, + server_port: None, + username: None, + win32_status: None, + query_name: None, + query_type: None, + response_code: None, + dns_direction: None, + dns_protocol: None, + source_ip: None, + dns_flags: None, + dns_event_id: None, + zone_name: None, + entry_kind: None, + whatif: None, + section_name: None, + section_color: None, + iteration: None, + tags: None, + }, + context: self.context, + line_start, + line_end, + timestamp: self.timestamp_parse, + } + } } pub(crate) fn truncate_subsecond_to_millis(value: &str) -> Option { @@ -63,22 +174,104 @@ pub(crate) fn truncate_subsecond_to_millis(value: &str) -> Option { } } +/// Longest offset any real timezone uses, in minutes (UTC-14:00..UTC+14:00). +const MAX_UTC_OFFSET_MINUTES: u32 = 14 * 60; + +/// Every timezone offset in current use is a whole number of quarter hours. +const UTC_OFFSET_STEP_MINUTES: u32 = 15; + +/// Decide whether a signless digit run can be a source timezone offset. +/// +/// The legacy grammar prints the offset with `%d`, so it never zero-pads and +/// never emits a sign for a positive value. A run that survives that shape +/// check still has to name an offset a machine can actually be configured +/// with; anything else is fractional-second text that happens to be numeric. +fn signless_offset_is_real(text: &str) -> bool { + if text.starts_with('0') { + return false; + } + + text.parse::().is_ok_and(|minutes| { + minutes <= MAX_UTC_OFFSET_MINUTES && minutes % UTC_OFFSET_STEP_MINUTES == 0 + }) +} + +/// Split CCM's fractional-second field from its optional timezone offset. +/// +/// A signed offset is self-delimiting and is always taken at face value: the +/// sign is the source stating its own provenance, so an out-of-range signed +/// offset is reported as invalid rather than reinterpreted. +/// +/// A signless tail is genuinely ambiguous. The documented legacy `%03u%d` +/// grammar emits three millisecond digits followed by an unsigned positive +/// offset, so `.000240` really is 0 ms at UTC+4; .NET writers instead emit +/// six- or seven-digit fractional seconds, so `.123456` is 123456 +/// microseconds and carries no offset. Both shapes are six digits wide, and +/// digit width alone cannot tell them apart. +/// +/// Two shipped implementations tried to tell them apart positionally and both +/// were wrong. The original greedy regex `(?P\d+)(?P[+-]*\d+)` gave +/// the last digit to the offset, so `.123456` became 123 ms at UTC+6 minutes. +/// Its replacement gave the last three digits to the offset whenever the tail +/// was exactly six wide, so `.123456` became 123 ms at UTC+456 minutes, a +/// silent 7h36m shift stamped `NormalizedUtc`. Do not add a third rule of +/// that kind: the split is decided by whether the candidate offset is a real +/// timezone offset, and a tail that fails that check keeps all of its digits +/// as fractional seconds and is reported as having no source offset. +fn split_ccm_time_tail(value: &str) -> (&str, Option<&str>) { + if let Some(index) = value + .as_bytes() + .iter() + .position(|byte| matches!(byte, b'+' | b'-')) + { + return (&value[..index], Some(&value[index..])); + } + + // `%03u%d` with a three-digit offset is the only signless shape the + // legacy grammar can produce that is not also plain fractional text. + if value.len() == 6 && signless_offset_is_real(&value[3..]) { + return (&value[..3], Some(&value[3..])); + } + + (value, None) +} + +/// Reproduce the pre-SCCM-spine public regex projection. +/// +/// The legacy `(?P\d+)(?P[+-]*\d+)` captures greedily assigned the +/// final digit of an unsigned tail to `timezoneOffset`. Public `LogEntry` +/// callers retain that observable behavior; the SCCM envelope uses +/// `split_ccm_time_tail` above for corrected provenance. +fn split_legacy_public_time_tail(value: &str) -> Option<(&str, &str)> { + if let Some(index) = value + .as_bytes() + .iter() + .position(|byte| matches!(byte, b'+' | b'-')) + { + return (index > 0).then_some((&value[..index], &value[index..])); + } + + let split_at = value.len().checked_sub(1)?; + (split_at > 0).then_some((&value[..split_at], &value[split_at..])) +} + /// Convert a naive local datetime + optional timezone offset (in minutes) to UTC epoch millis. /// Falls back to treating naive as UTC if the offset is invalid or overflows. pub(crate) fn naive_to_utc_millis( naive: chrono::NaiveDateTime, offset_minutes: Option, ) -> i64 { - if let Some(offset_minutes) = offset_minutes { - offset_minutes - .checked_mul(60) - .and_then(FixedOffset::east_opt) - .and_then(|offset| offset.from_local_datetime(&naive).single()) - .map(|dt| dt.timestamp_millis()) - .unwrap_or_else(|| naive.and_utc().timestamp_millis()) - } else { - naive.and_utc().timestamp_millis() - } + offset_minutes + .and_then(|offset| normalized_utc_millis(naive, offset)) + .unwrap_or_else(|| naive.and_utc().timestamp_millis()) +} + +fn normalized_utc_millis(naive: chrono::NaiveDateTime, offset_minutes: i32) -> Option { + offset_minutes + .checked_mul(60) + .and_then(FixedOffset::east_opt) + .and_then(|offset| offset.from_local_datetime(&naive).single()) + .map(|datetime| datetime.timestamp_millis()) } #[allow(clippy::too_many_arguments)] @@ -158,17 +351,206 @@ pub fn parse_content( /// matched as a single logical record. Text between matched records is /// emitted as individual plain-text entries, preserving line numbers. fn parse_content_multiline(content: &str, file_path: &str) -> (Vec, u32) { + let scan = scan_ccm_content(content, file_path, CcmScanMode::PublicProjection, None); + ( + scan.records + .into_iter() + .map(|record| record.entry) + .collect(), + scan.errors, + ) +} + +pub(crate) fn scan_logical_records(content: &str, file_path: &str) -> Vec { + scan_ccm_content(content, file_path, CcmScanMode::SccmEvidence, None) + .records + .into_iter() + .filter(|record| record.entry.format == LogFormat::Ccm) + .collect() +} + +/// Bounded SCCM evidence framing projection. This preserves the raw CCM +/// grammar while exposing whether a complete claimed payload contained +/// unmatched or ambiguous input that cannot be promoted to evidence. +pub(crate) struct CcmLogicalRecordScan { + pub records: Vec, + pub complete: bool, + pub record_limit_exceeded: bool, +} + +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CcmBoundedScanObservation { + pub record_limit: usize, + pub retained_records: usize, + pub record_limit_exceeded: bool, +} + +#[cfg(test)] +thread_local! { + static BOUNDED_SCAN_OBSERVATIONS: RefCell>> = + const { RefCell::new(None) }; +} + +#[cfg(test)] +struct BoundedScanObservationGuard; + +#[cfg(test)] +impl Drop for BoundedScanObservationGuard { + fn drop(&mut self) { + BOUNDED_SCAN_OBSERVATIONS.with(|observations| { + observations.borrow_mut().take(); + }); + } +} + +#[cfg(test)] +pub(crate) fn observe_bounded_scans( + operation: impl FnOnce() -> T, +) -> (T, Vec) { + BOUNDED_SCAN_OBSERVATIONS.with(|observations| { + assert!( + observations.replace(Some(Vec::new())).is_none(), + "bounded scan observation cannot be nested" + ); + }); + let _cleanup = BoundedScanObservationGuard; + let output = operation(); + let observations = BOUNDED_SCAN_OBSERVATIONS.with(|observations| { + observations + .borrow_mut() + .take() + .expect("bounded scan observation was installed") + }); + (output, observations) +} + +pub(crate) fn scan_logical_records_bounded( + content: &str, + file_path: &str, + max_records: usize, +) -> CcmLogicalRecordScan { + let scan = scan_ccm_content( + content, + file_path, + CcmScanMode::SccmEvidence, + Some(max_records), + ); + let bounded = CcmLogicalRecordScan { + records: scan + .records + .into_iter() + .filter(|record| record.entry.format == LogFormat::Ccm) + .collect(), + complete: scan.errors == 0 && !scan.record_limit_exceeded, + record_limit_exceeded: scan.record_limit_exceeded, + }; + #[cfg(test)] + BOUNDED_SCAN_OBSERVATIONS.with(|observations| { + if let Some(observations) = observations.borrow_mut().as_mut() { + observations.push(CcmBoundedScanObservation { + record_limit: max_records, + retained_records: bounded.records.len(), + record_limit_exceeded: bounded.record_limit_exceeded, + }); + } + }); + bounded +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CcmScanMode { + PublicProjection, + SccmEvidence, +} + +struct CcmScan { + records: Vec, + errors: u32, + record_limit_exceeded: bool, +} + +struct CcmScanBuild { + records: Vec, + errors: u32, + id_counter: u64, + record_limit: Option, + record_limit_exceeded: bool, +} + +impl CcmScanBuild { + fn new(record_limit: Option) -> Self { + Self { + records: Vec::new(), + errors: 0, + id_counter: 0, + record_limit, + record_limit_exceeded: false, + } + } + + fn record_limit_reached(&mut self) -> bool { + if self + .record_limit + .is_some_and(|limit| self.records.len() >= limit) + { + self.record_limit_exceeded = true; + true + } else { + false + } + } + + fn finish(self) -> CcmScan { + CcmScan { + records: self.records, + errors: self.errors, + record_limit_exceeded: self.record_limit_exceeded, + } + } +} + +fn scan_ccm_content( + content: &str, + file_path: &str, + mode: CcmScanMode, + record_limit: Option, +) -> CcmScan { let line_starts = build_line_starts(content); - let mut entries: Vec = Vec::new(); - let mut errors = 0u32; - let mut id_counter = 0u64; + let mut build = CcmScanBuild::new(record_limit); let mut cursor = 0usize; + let mut search_cursor = 0usize; let mut matched_any = false; - for caps in ccm_re().captures_iter(content) { - let Some(full_match) = caps.get(0) else { + while let Some(caps) = ccm_re().captures_at(content, search_cursor) { + let full_match = caps + .get(0) + .expect("CCM regex captures always include the full match"); + + let message_end = caps + .name("msg") + .expect("complete CCM matches always include the message capture") + .end(); + if newest_nested_opener(content, full_match.start(), message_end).is_some() + && mode == CcmScanMode::SccmEvidence + { + // A line-start opener inside a complete-looking multiline record + // is byte-for-byte ambiguous: it can be a literal message token + // or a recovery boundary after partial input. SCCM evidence must + // not promote either interpretation. The public projection keeps + // the legacy full-match result to preserve LogEntry compatibility. + push_unmatched_plain( + &content[cursor..full_match.end()], + cursor, + &line_starts, + file_path, + &mut build, + ); + cursor = full_match.end(); + search_cursor = full_match.end(); + matched_any = true; continue; - }; + } // Emit unmatched text between the previous match and this one push_unmatched_plain( @@ -176,78 +558,44 @@ fn parse_content_multiline(content: &str, file_path: &str) -> (Vec, u3 cursor, &line_starts, file_path, - &mut entries, - &mut id_counter, - &mut errors, + &mut build, ); - // Parse the matched CCM record - let line_number = line_number_for_offset(&line_starts, full_match.start()); + let line_start = line_number_for_offset(&line_starts, full_match.start()); + let line_end = line_number_for_offset(&line_starts, full_match.end().saturating_sub(1)); if let Some(parsed) = parse_captures(&caps) { - entries.push(LogEntry { - id: id_counter, - line_number, - message: parsed.message, - component: parsed.component, - timestamp: parsed.timestamp, - timestamp_display: parsed.timestamp_display, - severity: parsed.severity, - thread: Some(parsed.thread), - thread_display: parsed.thread_display, - source_file: parsed.source_file, - format: LogFormat::Ccm, - file_path: file_path.to_string(), - timezone_offset: Some(parsed.timezone_offset), - error_code_spans: Vec::new(), - ip_address: None, - host_name: None, - mac_address: None, - result_code: None, - gle_code: None, - setup_phase: None, - operation_name: None, - http_method: None, - uri_stem: None, - uri_query: None, - status_code: None, - sub_status: None, - time_taken_ms: None, - client_ip: None, - server_ip: None, - user_agent: None, - server_port: None, - username: None, - win32_status: None, - query_name: None, - query_type: None, - response_code: None, - dns_direction: None, - dns_protocol: None, - source_ip: None, - dns_flags: None, - dns_event_id: None, - zone_name: None, - entry_kind: None, - whatif: None, - section_name: None, - section_color: None, - iteration: None, - tags: None, - }); - id_counter += 1; + if parsed.public_compatible || mode == CcmScanMode::SccmEvidence { + if build.record_limit_reached() { + return build.finish(); + } + build.records.push(parsed.into_logical_record( + build.id_counter, + line_start, + line_end, + file_path, + )); + build.id_counter += 1; + } else { + push_unmatched_plain( + full_match.as_str(), + full_match.start(), + &line_starts, + file_path, + &mut build, + ); + } } else { push_unmatched_plain( full_match.as_str(), full_match.start(), &line_starts, file_path, - &mut entries, - &mut id_counter, - &mut errors, + &mut build, ); } cursor = full_match.end(); + search_cursor = full_match.end(); matched_any = true; } @@ -257,33 +605,85 @@ fn parse_content_multiline(content: &str, file_path: &str) -> (Vec, u3 cursor, &line_starts, file_path, - &mut entries, - &mut id_counter, - &mut errors, + &mut build, ); + if !matched_any && record_limit.is_some() { + return build.finish(); + } + if !matched_any { - // No CCM records found at all — fall back to line-by-line let lines: Vec<&str> = content.lines().collect(); - return parse_lines(&lines, file_path); + let (entries, errors) = parse_lines(&lines, file_path); + let records = entries + .into_iter() + .map(|entry| { + let line = entry.line_number; + CcmLogicalRecord { + entry, + context: None, + line_start: line, + line_end: line, + timestamp: CcmTimestampParse::missing(), + } + }) + .collect(); + return CcmScan { + records, + errors, + record_limit_exceeded: build.record_limit_exceeded, + }; } - (entries, errors) + build.finish() +} + +fn newest_nested_opener( + content: &str, + full_match_start: usize, + message_end: usize, +) -> Option { + // Only physical-line openers inside the message can delimit recovery + // records. Attribute values are outside the raw CCM payload and may + // contain literal opener text. Scan newest first so an adversarial run of + // partial message openers is discarded in one pass. + let nested_search_start = full_match_start.checked_add(CCM_RECORD_OPENER.len())?; + content + .get(nested_search_start..message_end)? + .rmatch_indices(CCM_RECORD_OPENER) + .find_map(|(offset, _)| { + let absolute = nested_search_start + offset; + let starts_logical_line = absolute == 0 + || content + .as_bytes() + .get(absolute - 1) + .is_some_and(|previous| matches!(previous, b'\n' | b'\r')); + starts_logical_line.then_some(absolute) + }) } /// Parse named captures from a CCM regex match into a CcmParsed struct. fn parse_captures(caps: ®ex::Captures<'_>) -> Option { let msg = caps.name("msg").map(|m| m.as_str().to_string())?; - let h: u32 = caps.name("h")?.as_str().parse().ok()?; - let m: u32 = caps.name("m")?.as_str().parse().ok()?; - let s: u32 = caps.name("s")?.as_str().parse().ok()?; - let ms_str = caps.name("ms")?.as_str(); + let h_text = caps.name("h")?.as_str(); + let m_text = caps.name("m")?.as_str(); + let s_text = caps.name("s")?.as_str(); + let h: u32 = h_text.parse().ok()?; + let m: u32 = m_text.parse().ok()?; + let s: u32 = s_text.parse().ok()?; + let time_tail = caps.name("time_tail")?.as_str(); + let (ms_str, timezone_text) = split_ccm_time_tail(time_tail); let ms = truncate_subsecond_to_millis(ms_str)?; - let tz: i32 = caps.name("tz")?.as_str().parse().ok()?; - let mon: u32 = caps.name("mon")?.as_str().parse().ok()?; - let day: u32 = caps.name("day")?.as_str().parse().ok()?; - let yr: i32 = caps.name("yr")?.as_str().parse().ok()?; + let parsed_timezone = timezone_text.and_then(|value| value.parse::().ok()); + let timezone_is_explicit = timezone_text.is_some(); + let mon_text = caps.name("mon")?.as_str(); + let day_text = caps.name("day")?.as_str(); + let yr_text = caps.name("yr")?.as_str(); + let mon: u32 = mon_text.parse().ok()?; + let day: u32 = day_text.parse().ok()?; + let yr: i32 = yr_text.parse().ok()?; let comp = caps.name("comp").map(|m| m.as_str().to_string()); + let context = caps.name("context").map(|m| m.as_str().to_string()); // Preserve absent/empty/unparseable type as `None` so it falls back to // text-based detection. Coercing to `Some(0)` would misclassify neutral // lines as Success now that type="0" maps to Severity::Success. @@ -295,7 +695,51 @@ fn parse_captures(caps: ®ex::Captures<'_>) -> Option { let file = caps.name("file").map(|m| m.as_str().to_string()); let severity = severity_from_type_field(typ, &msg); - let (timestamp, timestamp_display) = build_timestamp(mon, day, yr, h, m, s, ms, Some(tz)); + let public_timestamp = split_legacy_public_time_tail(time_tail).and_then( + |(public_ms_text, public_timezone_text)| { + let public_ms = truncate_subsecond_to_millis(public_ms_text)?; + let public_timezone = public_timezone_text.parse::().ok()?; + let (timestamp, timestamp_display) = + build_timestamp(mon, day, yr, h, m, s, public_ms, Some(public_timezone)); + Some((timestamp, timestamp_display, public_timezone)) + }, + ); + let public_compatible = public_timestamp.is_some(); + let (timestamp, timestamp_display, timezone_offset) = public_timestamp.unwrap_or_else(|| { + let (timestamp, timestamp_display) = + build_timestamp(mon, day, yr, h, m, s, ms, parsed_timezone); + ( + timestamp, + timestamp_display, + parsed_timezone.unwrap_or_default(), + ) + }); + let naive = chrono::NaiveDate::from_ymd_opt(yr, mon, day) + .and_then(|date| date.and_hms_milli_opt(h, m, s, ms)); + let normalized_timestamp = if timezone_is_explicit { + naive.and_then(|value| { + parsed_timezone.and_then(|offset| normalized_utc_millis(value, offset)) + }) + } else { + None + }; + let ordering_state = if naive.is_none() { + CcmTimestampParseState::TimestampMissing + } else if !timezone_is_explicit { + CcmTimestampParseState::OffsetMissing + } else if normalized_timestamp.is_some() { + CcmTimestampParseState::NormalizedUtc + } else { + CcmTimestampParseState::OffsetInvalid + }; + let timestamp_parse = CcmTimestampParse { + original_display: Some(format!( + "{mon_text}-{day_text}-{yr_text} {h_text}:{m_text}:{s_text}.{ms_str}" + )), + offset_minutes: timezone_is_explicit.then_some(parsed_timezone).flatten(), + utc_millis: normalized_timestamp, + ordering_state, + }; let thread_display = Some(format_thread_display(thr)); Some(CcmParsed { @@ -307,7 +751,10 @@ fn parse_captures(caps: ®ex::Captures<'_>) -> Option { thread: thr, thread_display, source_file: file, - timezone_offset: tz, + timezone_offset, + context, + timestamp_parse, + public_compatible, }) } @@ -328,24 +775,26 @@ fn line_number_for_offset(line_starts: &[usize], offset: usize) -> u32 { } } -/// Emit each non-empty physical line in `segment` as a plain-text LogEntry. +/// Emit each non-empty physical line in `segment` as a plain-text envelope. fn push_unmatched_plain( segment: &str, base_offset: usize, line_starts: &[usize], file_path: &str, - entries: &mut Vec, - id_counter: &mut u64, - errors: &mut u32, + build: &mut CcmScanBuild, ) { let mut local_offset = 0usize; for piece in segment.split_inclusive('\n') { let line = piece.trim_end_matches(['\r', '\n']); let trimmed = line.trim(); if !trimmed.is_empty() { - entries.push(LogEntry { - id: *id_counter, - line_number: line_number_for_offset(line_starts, base_offset + local_offset), + if build.record_limit_reached() { + return; + } + let line_number = line_number_for_offset(line_starts, base_offset + local_offset); + let entry = LogEntry { + id: build.id_counter, + line_number, message: trimmed.to_string(), component: None, timestamp: None, @@ -392,9 +841,16 @@ fn push_unmatched_plain( section_color: None, iteration: None, tags: None, + }; + build.records.push(CcmLogicalRecord { + entry, + context: None, + line_start: line_number, + line_end: line_number, + timestamp: CcmTimestampParse::missing(), }); - *id_counter += 1; - *errors += 1; + build.id_counter += 1; + build.errors += 1; } local_offset += piece.len(); } @@ -423,56 +879,12 @@ pub fn parse_lines(lines: &[&str], file_path: &str) -> (Vec, u32) { match parse_line(line) { Some(parsed) => { - entries.push(LogEntry { - id: id_counter, - line_number: (i + 1) as u32, - message: parsed.message, - component: parsed.component, - timestamp: parsed.timestamp, - timestamp_display: parsed.timestamp_display, - severity: parsed.severity, - thread: Some(parsed.thread), - thread_display: parsed.thread_display, - source_file: parsed.source_file, - format: LogFormat::Ccm, - file_path: file_path.to_string(), - timezone_offset: Some(parsed.timezone_offset), - error_code_spans: Vec::new(), - ip_address: None, - host_name: None, - mac_address: None, - result_code: None, - gle_code: None, - setup_phase: None, - operation_name: None, - http_method: None, - uri_stem: None, - uri_query: None, - status_code: None, - sub_status: None, - time_taken_ms: None, - client_ip: None, - server_ip: None, - user_agent: None, - server_port: None, - username: None, - win32_status: None, - query_name: None, - query_type: None, - response_code: None, - dns_direction: None, - dns_protocol: None, - source_ip: None, - dns_flags: None, - dns_event_id: None, - zone_name: None, - entry_kind: None, - whatif: None, - section_name: None, - section_color: None, - iteration: None, - tags: None, - }); + let line_number = (i + 1) as u32; + entries.push( + parsed + .into_logical_record(id_counter, line_number, line_number, file_path) + .entry, + ); id_counter += 1; } None => { @@ -541,6 +953,17 @@ pub fn parse_lines(lines: &[&str], file_path: &str) -> (Vec, u32) { mod tests { use super::*; + #[test] + fn bounded_scan_observer_clears_after_a_panicking_operation() { + let panic = std::panic::catch_unwind(|| { + let _ = observe_bounded_scans(|| panic!("expected observer probe panic")); + }); + assert!(panic.is_err()); + + let (_, observations) = observe_bounded_scans(|| ()); + assert!(observations.is_empty()); + } + #[test] fn test_parse_ccm_line() { let line = r#""#; @@ -771,4 +1194,287 @@ mod tests { ); assert!(display.is_some(), "display should always be present"); } + + #[test] + fn logical_scanner_retains_multiline_metadata() { + let text = concat!( + "", + "" + ); + + let records = scan_logical_records(text, "PolicyAgent.log"); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].line_start, 1); + assert_eq!(records[0].line_end, 2); + assert_eq!(records[0].context.as_deref(), Some(r"NT AUTHORITY\SYSTEM")); + assert_eq!( + records[0].timestamp.original_display.as_deref(), + Some("07-30-2026 10:00:00.000") + ); + assert_eq!(records[0].timestamp.offset_minutes, Some(-240)); + assert_eq!( + records[0].timestamp.ordering_state, + CcmTimestampParseState::NormalizedUtc + ); + assert!(records[0].timestamp.utc_millis.is_some()); + } + + #[test] + fn logical_scanner_treats_line_start_opener_inside_multiline_message_as_ambiguous() { + for newline in ["\n", "\r\n"] { + let text = format!( + concat!( + "", + "" + ), + newline = newline, + ); + + assert!( + scan_logical_records(&text, "PolicyAgent.log").is_empty(), + "{newline:?} physical-line ambiguity must remain coverage-only" + ); + } + } + + #[test] + fn public_projection_keeps_multiline_line_start_literal_opener_as_one_log_entry() { + for newline in ["\n", "\r\n"] { + let text = format!( + concat!( + "", + "" + ), + newline = newline, + ); + + let (entries, errors) = parse_content(&text, "PolicyAgent.log", None); + + assert_eq!(errors, 0, "{newline:?} public parse must remain compatible"); + assert_eq!(entries.len(), 1, "{newline:?} must remain one LogEntry"); + assert_eq!(entries[0].format, LogFormat::Ccm); + assert_eq!( + entries[0].message, + format!("first line{newline}", + "" + ); + + let records = scan_logical_records(text, "PolicyAgent.log"); + + assert_eq!(records.len(), 1); + assert_eq!( + records[0].entry.message, + "Diagnostic text retained a literal ", + "" + ); + + let records = scan_logical_records(text, "PolicyAgent.log"); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].entry.message, "Policy request completed"); + assert_eq!( + records[0].context.as_deref(), + Some("diagnostic continuation\n" + ), + partial_prefix = partial_prefix, + ); + let full_match = ccm_re() + .find(&text) + .expect("the partial prefix and terminal close form one complete-looking match"); + + assert_eq!( + newest_nested_opener(&text, full_match.start(), full_match.end()), + text.rfind(" CcmLogicalRecord { + let text = format!( + concat!( + r#""# + ), + tail = tail + ); + let mut records = scan_logical_records(&text, "PolicyAgent.log"); + assert_eq!(records.len(), 1, "tail {tail}: expected one logical record"); + records.remove(0) + } + + /// Frozen answers for every shape of CCM fractional-second tail. + /// + /// Two shipped implementations resolved this ambiguity by counting digits + /// and both were wrong (see `split_ccm_time_tail`). This table is the + /// regression barrier: it pins the fractional text, the millisecond value, + /// the source offset, and the ordering confidence for unsigned tails of + /// one to eight digits and for their signed counterparts. Change a row + /// only with a documented grammar reason, never to let a new heuristic + /// pass. + #[test] + fn ccm_time_tail_ambiguity_table_is_frozen() { + use CcmTimestampParseState::{NormalizedUtc, OffsetInvalid, OffsetMissing}; + + // (time tail, fractional text, milliseconds, source offset, ordering state) + let cases: [(&str, &str, u32, Option, CcmTimestampParseState); 26] = [ + // Unsigned tails that cannot be `%03u%d`: no trailing run of + // digits reads as a real UTC offset, so they stay fractional and + // the record is never promoted to UTC-normalized ordering. + ("1", "1", 1, None, OffsetMissing), + ("12", "12", 12, None, OffsetMissing), + ("123", "123", 123, None, OffsetMissing), + // `%d` prints a zero offset as "0", so a four-digit tail is not + // evidence of one; downgrade instead of guessing. + ("1234", "1234", 123, None, OffsetMissing), + ("12345", "12345", 123, None, OffsetMissing), + // Microsecond precision. 456 is not a UTC offset. + ("123456", "123456", 123, None, OffsetMissing), + // `%d` never zero-pads, so "045" is fractional text and not +45. + ("123045", "123045", 123, None, OffsetMissing), + // Same rule: a padded zero offset always arrives signed ("+000"). + ("123000", "123000", 123, None, OffsetMissing), + // 481 minutes is not a whole quarter hour. + ("123481", "123481", 123, None, OffsetMissing), + // 900 minutes exceeds UTC+14:00. + ("123900", "123900", 123, None, OffsetMissing), + // Unsigned tails that are genuine `%03u%d` records: three + // millisecond digits followed by a positive offset. + ("000240", "000", 0, Some(240), NormalizedUtc), + ("123480", "123", 123, Some(480), NormalizedUtc), + ("123840", "123", 123, Some(840), NormalizedUtc), + ("123105", "123", 123, Some(105), NormalizedUtc), + // IME writes seven fractional digits, never an offset. + ("1234567", "1234567", 123, None, OffsetMissing), + ("12345678", "12345678", 123, None, OffsetMissing), + // Signed tails are self-delimiting: the sign is the source's own + // statement of provenance and is never screened for plausibility. + ("123+480", "123", 123, Some(480), NormalizedUtc), + ("123-240", "123", 123, Some(-240), NormalizedUtc), + ("000+000", "000", 0, Some(0), NormalizedUtc), + ("123+481", "123", 123, Some(481), NormalizedUtc), + ("123+0", "123", 123, Some(0), NormalizedUtc), + ("1+2", "1", 1, Some(2), NormalizedUtc), + ("123456+480", "123456", 123, Some(480), NormalizedUtc), + ("1234567-060", "1234567", 123, Some(-60), NormalizedUtc), + // Out-of-range signed offsets stay reported and stay uncomparable. + ("123+99999", "123", 123, Some(99999), OffsetInvalid), + ("123-99999", "123", 123, Some(-99999), OffsetInvalid), + ]; + + for (tail, fraction_text, millis, offset_minutes, ordering_state) in cases { + let (split_fraction, split_offset) = split_ccm_time_tail(tail); + assert_eq!( + split_fraction, fraction_text, + "tail {tail}: fractional text" + ); + assert_eq!( + split_offset.is_some(), + offset_minutes.is_some(), + "tail {tail}: offset presence" + ); + assert_eq!( + truncate_subsecond_to_millis(split_fraction), + Some(millis), + "tail {tail}: milliseconds" + ); + + let record = logical_record_for_time_tail(tail); + let timestamp = &record.timestamp; + assert_eq!( + timestamp.original_display, + Some(format!("07-30-2026 10:00:00.{fraction_text}")), + "tail {tail}: original display" + ); + assert_eq!( + timestamp.offset_minutes, offset_minutes, + "tail {tail}: source offset" + ); + assert_eq!( + timestamp.ordering_state, ordering_state, + "tail {tail}: ordering state" + ); + + let naive = chrono::NaiveDate::from_ymd_opt(2026, 7, 30) + .unwrap() + .and_hms_milli_opt(10, 0, 0, millis) + .unwrap(); + let expected_utc = + offset_minutes.and_then(|minutes| normalized_utc_millis(naive, minutes)); + assert_eq!( + timestamp.utc_millis, expected_utc, + "tail {tail}: normalized utc" + ); + assert_eq!( + timestamp.utc_millis.is_some(), + ordering_state == NormalizedUtc, + "tail {tail}: utc millis must exist exactly when ordering is normalized" + ); + } + } + + /// Both shipped attempts fabricated an offset from a microsecond tail. + #[test] + fn ccm_time_tail_rejects_both_historical_offset_bugs() { + let timestamp = logical_record_for_time_tail("123456").timestamp; + + assert_ne!( + timestamp.offset_minutes, + Some(6), + "greedy-regex attempt read the final digit as the offset" + ); + assert_ne!( + timestamp.offset_minutes, + Some(456), + "digit-count attempt read the final three digits as the offset" + ); + assert_eq!(timestamp.offset_minutes, None); + assert_eq!( + timestamp.ordering_state, + CcmTimestampParseState::OffsetMissing + ); + assert_eq!(timestamp.utc_millis, None); + } } diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs new file mode 100644 index 000000000..b8f020930 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -0,0 +1,878 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use super::rotation::{is_canonical_rotation_timestamp, parse_canonical_rotation_number}; +use super::{SccmRole, SccmRotation, SccmUnknownRotation}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SccmArtifactFamily { + ClientSetup, + ClientHealth, + ClientIdentity, + ClientLocation, + ClientPolicy, + ClientContent, + ClientApplication, + ClientUpdates, + ClientTaskSequence, + ClientInventory, + ClientCompliance, + ClientMetering, + SiteComponent, + SiteStatus, + ManagementPoint, + DistributionPoint, + SoftwareUpdatePoint, + Hierarchy, + Provider, + AdminService, + Unknown(String), +} + +impl SccmArtifactFamily { + pub(crate) fn serialized_name(&self) -> &str { + match self { + Self::ClientSetup => "clientSetup", + Self::ClientHealth => "clientHealth", + Self::ClientIdentity => "clientIdentity", + Self::ClientLocation => "clientLocation", + Self::ClientPolicy => "clientPolicy", + Self::ClientContent => "clientContent", + Self::ClientApplication => "clientApplication", + Self::ClientUpdates => "clientUpdates", + Self::ClientTaskSequence => "clientTaskSequence", + Self::ClientInventory => "clientInventory", + Self::ClientCompliance => "clientCompliance", + Self::ClientMetering => "clientMetering", + Self::SiteComponent => "siteComponent", + Self::SiteStatus => "siteStatus", + Self::ManagementPoint => "managementPoint", + Self::DistributionPoint => "distributionPoint", + Self::SoftwareUpdatePoint => "softwareUpdatePoint", + Self::Hierarchy => "hierarchy", + Self::Provider => "provider", + Self::AdminService => "adminService", + Self::Unknown(value) => value, + } + } +} + +impl Serialize for SccmArtifactFamily { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.serialized_name()) + } +} + +impl<'de> Deserialize<'de> for SccmArtifactFamily { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(match String::deserialize(deserializer)? { + value if value == "clientSetup" => Self::ClientSetup, + value if value == "clientHealth" => Self::ClientHealth, + value if value == "clientIdentity" => Self::ClientIdentity, + value if value == "clientLocation" => Self::ClientLocation, + value if value == "clientPolicy" => Self::ClientPolicy, + value if value == "clientContent" => Self::ClientContent, + value if value == "clientApplication" => Self::ClientApplication, + value if value == "clientUpdates" => Self::ClientUpdates, + value if value == "clientTaskSequence" => Self::ClientTaskSequence, + value if value == "clientInventory" => Self::ClientInventory, + value if value == "clientCompliance" => Self::ClientCompliance, + value if value == "clientMetering" => Self::ClientMetering, + value if value == "siteComponent" => Self::SiteComponent, + value if value == "siteStatus" => Self::SiteStatus, + value if value == "managementPoint" => Self::ManagementPoint, + value if value == "distributionPoint" => Self::DistributionPoint, + value if value == "softwareUpdatePoint" => Self::SoftwareUpdatePoint, + value if value == "hierarchy" => Self::Hierarchy, + value if value == "provider" => Self::Provider, + value if value == "adminService" => Self::AdminService, + value => Self::Unknown(value), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSourceCatalogEntry { + pub basename: String, + pub logical_name: String, + pub role: SccmRole, + pub family: SccmArtifactFamily, + pub rotation: SccmRotation, + pub uses_ccm_records: bool, + pub supported_for_diagnosis: bool, +} + +struct CatalogSpec { + basename: &'static str, + logical_name: &'static str, + role: SccmRole, + family: SccmArtifactFamily, +} + +/// Immutable client intake membership owned by the shared SCCM source +/// catalog. A physical source may feed more than one logical intake group; +/// `LocationServices.log` is intentionally captured once and projected into +/// both location and content coverage. +#[derive(Clone, Copy)] +pub(crate) struct SccmClientSourceMembership { + pub basename: &'static str, + pub logical_artifact_ids: &'static [&'static str], +} + +const CLIENT_SOURCE_MEMBERSHIPS: &[SccmClientSourceMembership] = &[ + SccmClientSourceMembership { + basename: "AppEnforce.log", + logical_artifact_ids: &["client-app-enforce"], + }, + SccmClientSourceMembership { + basename: "ExecMgr.log", + logical_artifact_ids: &["client-app-enforce"], + }, + SccmClientSourceMembership { + basename: "AppDiscovery.log", + logical_artifact_ids: &["client-app-intent"], + }, + SccmClientSourceMembership { + basename: "AppIntentEval.log", + logical_artifact_ids: &["client-app-intent"], + }, + SccmClientSourceMembership { + basename: "ccmsetup.log", + logical_artifact_ids: &["client-ccmsetup"], + }, + SccmClientSourceMembership { + basename: "client.msi.log", + logical_artifact_ids: &["client-ccmsetup"], + }, + SccmClientSourceMembership { + basename: "CAS.log", + logical_artifact_ids: &["client-content"], + }, + SccmClientSourceMembership { + basename: "ContentTransferManager.log", + logical_artifact_ids: &["client-content"], + }, + SccmClientSourceMembership { + basename: "DataTransferService.log", + logical_artifact_ids: &["client-content"], + }, + SccmClientSourceMembership { + basename: "CcmEval.log", + logical_artifact_ids: &["client-evaluation"], + }, + SccmClientSourceMembership { + basename: "CcmExec.log", + logical_artifact_ids: &["client-evaluation"], + }, + SccmClientSourceMembership { + basename: "CcmRestart.log", + logical_artifact_ids: &["client-evaluation"], + }, + SccmClientSourceMembership { + basename: "ClientIDManagerStartup.log", + logical_artifact_ids: &["client-identity"], + }, + SccmClientSourceMembership { + basename: "CcmMessaging.log", + logical_artifact_ids: &["client-location"], + }, + SccmClientSourceMembership { + basename: "ClientLocation.log", + logical_artifact_ids: &["client-location"], + }, + SccmClientSourceMembership { + basename: "LocationServices.log", + logical_artifact_ids: &[ + "client-location", + "client-content", + "client-location-services-shared", + ], + }, + SccmClientSourceMembership { + basename: "PolicyAgent.log", + logical_artifact_ids: &["client-policy-agent"], + }, + SccmClientSourceMembership { + basename: "PolicyAgentProvider.log", + logical_artifact_ids: &["client-policy-agent"], + }, + SccmClientSourceMembership { + basename: "PolicyEvaluator.log", + logical_artifact_ids: &["client-policy-agent"], + }, + SccmClientSourceMembership { + basename: "Scheduler.log", + logical_artifact_ids: &["client-policy-agent"], + }, + SccmClientSourceMembership { + basename: "CIAgent.log", + logical_artifact_ids: &["client-policy-state"], + }, + SccmClientSourceMembership { + basename: "CIDownloader.log", + logical_artifact_ids: &["client-policy-state"], + }, + SccmClientSourceMembership { + basename: "StateMessage.log", + logical_artifact_ids: &["client-policy-state"], + }, + SccmClientSourceMembership { + basename: "StatusAgent.log", + logical_artifact_ids: &["client-policy-state"], + }, + SccmClientSourceMembership { + basename: "ScanAgent.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "UpdatesDeployment.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "UpdatesHandler.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "UpdatesStore.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "WUAHandler.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "ServiceWindowManager.log", + logical_artifact_ids: &["client-maintenance-window"], + }, + SccmClientSourceMembership { + basename: "RebootCoordinator.log", + logical_artifact_ids: &["client-reboot"], + }, + SccmClientSourceMembership { + basename: "CBS.log", + logical_artifact_ids: &["client-windows-update-supplemental"], + }, + SccmClientSourceMembership { + basename: "ReportingEvents.log", + logical_artifact_ids: &["client-windows-update-supplemental"], + }, + SccmClientSourceMembership { + basename: "smsts.log", + logical_artifact_ids: &["client-task-sequence-smsts"], + }, + SccmClientSourceMembership { + basename: "InventoryAgent.log", + logical_artifact_ids: &["client-inventory"], + }, + SccmClientSourceMembership { + basename: "InventoryProvider.log", + logical_artifact_ids: &["client-inventory"], + }, + SccmClientSourceMembership { + basename: "InventoryAgentProvider.log", + logical_artifact_ids: &["client-inventory"], + }, + SccmClientSourceMembership { + basename: "CITaskMgr.log", + logical_artifact_ids: &["client-compliance"], + }, + SccmClientSourceMembership { + basename: "DCMAgent.log", + logical_artifact_ids: &["client-compliance"], + }, + SccmClientSourceMembership { + basename: "DCMReporting.log", + logical_artifact_ids: &["client-compliance"], + }, + SccmClientSourceMembership { + basename: "SWMTRReportGen.log", + logical_artifact_ids: &["client-metering"], + }, +]; + +pub(crate) fn declared_client_source_memberships() -> &'static [SccmClientSourceMembership] { + CLIENT_SOURCE_MEMBERSHIPS +} + +const SOURCE_CATALOG: &[CatalogSpec] = &[ + CatalogSpec { + basename: "ccmsetup", + logical_name: "ccmSetup", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientSetup, + }, + CatalogSpec { + basename: "client.msi", + logical_name: "clientMsi", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientSetup, + }, + CatalogSpec { + basename: "CcmEval", + logical_name: "ccmEval", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientHealth, + }, + CatalogSpec { + basename: "CcmExec", + logical_name: "ccmExec", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientHealth, + }, + CatalogSpec { + basename: "CcmRestart", + logical_name: "ccmRestart", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientHealth, + }, + CatalogSpec { + basename: "ClientIDManagerStartup", + logical_name: "clientIdManagerStartup", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientIdentity, + }, + CatalogSpec { + basename: "ClientLocation", + logical_name: "clientLocation", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientLocation, + }, + CatalogSpec { + basename: "LocationServices", + logical_name: "locationServices", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientLocation, + }, + CatalogSpec { + basename: "CcmMessaging", + logical_name: "ccmMessaging", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientLocation, + }, + CatalogSpec { + basename: "PolicyAgent", + logical_name: "policyAgent", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "PolicyAgentProvider", + logical_name: "policyAgentProvider", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "PolicyEvaluator", + logical_name: "policyEvaluator", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "CIAgent", + logical_name: "ciAgent", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "CIDownloader", + logical_name: "ciDownloader", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "StateMessage", + logical_name: "stateMessage", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "StatusAgent", + logical_name: "statusAgent", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "Scheduler", + logical_name: "scheduler", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "CAS", + logical_name: "cas", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientContent, + }, + CatalogSpec { + basename: "ContentTransferManager", + logical_name: "contentTransferManager", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientContent, + }, + CatalogSpec { + basename: "DataTransferService", + logical_name: "dataTransferService", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientContent, + }, + CatalogSpec { + basename: "AppIntentEval", + logical_name: "appIntentEval", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientApplication, + }, + CatalogSpec { + basename: "AppDiscovery", + logical_name: "appDiscovery", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientApplication, + }, + CatalogSpec { + basename: "AppEnforce", + logical_name: "appEnforce", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientApplication, + }, + CatalogSpec { + basename: "ExecMgr", + logical_name: "execMgr", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientApplication, + }, + CatalogSpec { + basename: "ScanAgent", + logical_name: "scanAgent", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, + CatalogSpec { + basename: "WUAHandler", + logical_name: "wuaHandler", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, + CatalogSpec { + basename: "UpdatesDeployment", + logical_name: "updatesDeployment", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, + CatalogSpec { + basename: "UpdatesHandler", + logical_name: "updatesHandler", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, + CatalogSpec { + basename: "UpdatesStore", + logical_name: "updatesStore", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, + CatalogSpec { + basename: "ServiceWindowManager", + logical_name: "serviceWindowManager", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, + CatalogSpec { + basename: "RebootCoordinator", + logical_name: "rebootCoordinator", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, + CatalogSpec { + basename: "CBS", + logical_name: "componentBasedServicing", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, + CatalogSpec { + basename: "ReportingEvents", + logical_name: "reportingEvents", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, + CatalogSpec { + basename: "smsts", + logical_name: "smsts", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientTaskSequence, + }, + CatalogSpec { + basename: "InventoryAgent", + logical_name: "inventoryAgent", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientInventory, + }, + CatalogSpec { + basename: "InventoryProvider", + logical_name: "inventoryProvider", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientInventory, + }, + CatalogSpec { + basename: "InventoryAgentProvider", + logical_name: "inventoryAgentProvider", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientInventory, + }, + CatalogSpec { + basename: "CITaskMgr", + logical_name: "ciTaskMgr", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientCompliance, + }, + CatalogSpec { + basename: "DCMAgent", + logical_name: "dcmAgent", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientCompliance, + }, + CatalogSpec { + basename: "DCMReporting", + logical_name: "dcmReporting", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientCompliance, + }, + CatalogSpec { + basename: "SWMTRReportGen", + logical_name: "swmtrReportGen", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientMetering, + }, + CatalogSpec { + basename: "sitecomp", + logical_name: "sitecomp", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::SiteComponent, + }, + CatalogSpec { + basename: "hman", + logical_name: "hman", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::SiteComponent, + }, + CatalogSpec { + basename: "statmgr", + logical_name: "statmgr", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::SiteStatus, + }, + CatalogSpec { + basename: "statesys", + logical_name: "statesys", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::SiteStatus, + }, + CatalogSpec { + basename: "MP_CliReg", + logical_name: "mpCliReg", + role: SccmRole::ManagementPoint, + family: SccmArtifactFamily::ManagementPoint, + }, + CatalogSpec { + basename: "MP_GetAuth", + logical_name: "mpGetAuth", + role: SccmRole::ManagementPoint, + family: SccmArtifactFamily::ManagementPoint, + }, + CatalogSpec { + basename: "MP_GetPolicy", + logical_name: "mpGetPolicy", + role: SccmRole::ManagementPoint, + family: SccmArtifactFamily::ManagementPoint, + }, + CatalogSpec { + basename: "MP_Location", + logical_name: "mpLocation", + role: SccmRole::ManagementPoint, + family: SccmArtifactFamily::ManagementPoint, + }, + CatalogSpec { + basename: "MP_RegistrationManager", + logical_name: "mpRegistrationManager", + role: SccmRole::ManagementPoint, + family: SccmArtifactFamily::ManagementPoint, + }, + CatalogSpec { + basename: "mpcontrol", + logical_name: "mpcontrol", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::ManagementPoint, + }, + CatalogSpec { + basename: "distmgr", + logical_name: "distmgr", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::DistributionPoint, + }, + CatalogSpec { + basename: "PkgXferMgr", + logical_name: "pkgXferMgr", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::DistributionPoint, + }, + CatalogSpec { + basename: "SMSDPProv", + logical_name: "smsDpProv", + role: SccmRole::DistributionPoint, + family: SccmArtifactFamily::DistributionPoint, + }, + CatalogSpec { + basename: "SMSdpmon", + logical_name: "smsDpmon", + role: SccmRole::DistributionPoint, + family: SccmArtifactFamily::DistributionPoint, + }, + CatalogSpec { + basename: "PullDP", + logical_name: "pullDp", + role: SccmRole::DistributionPoint, + family: SccmArtifactFamily::DistributionPoint, + }, + CatalogSpec { + basename: "WCM", + logical_name: "wcm", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::SoftwareUpdatePoint, + }, + CatalogSpec { + basename: "WSUSCtrl", + logical_name: "wsusCtrl", + role: SccmRole::SoftwareUpdatePoint, + family: SccmArtifactFamily::SoftwareUpdatePoint, + }, + CatalogSpec { + basename: "wsyncmgr", + logical_name: "wsyncmgr", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::SoftwareUpdatePoint, + }, + CatalogSpec { + basename: "SUPSetup", + logical_name: "supSetup", + role: SccmRole::SoftwareUpdatePoint, + family: SccmArtifactFamily::SoftwareUpdatePoint, + }, + CatalogSpec { + basename: "replmgr", + logical_name: "replmgr", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::Hierarchy, + }, + CatalogSpec { + basename: "rcmctrl", + logical_name: "rcmctrl", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::Hierarchy, + }, + CatalogSpec { + basename: "sender", + logical_name: "sender", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::Hierarchy, + }, + CatalogSpec { + basename: "despool", + logical_name: "despool", + role: SccmRole::SiteServer, + family: SccmArtifactFamily::Hierarchy, + }, + CatalogSpec { + basename: "Smsprov", + logical_name: "smsprov", + role: SccmRole::Provider, + family: SccmArtifactFamily::Provider, + }, + CatalogSpec { + basename: "AdminService", + logical_name: "adminService", + role: SccmRole::AdminService, + family: SccmArtifactFamily::AdminService, + }, +]; + +pub fn classify_artifact_name(name: &str, role: SccmRole) -> SccmSourceCatalogEntry { + let parsed = ParsedArtifactName::from_name(name); + let known = SOURCE_CATALOG + .iter() + .find(|entry| entry.basename.eq_ignore_ascii_case(parsed.basename) && entry.role == role); + + if let Some(entry) = known { + return SccmSourceCatalogEntry { + basename: format!("{}.log", entry.basename), + logical_name: entry.logical_name.to_string(), + role, + family: entry.family.clone(), + rotation: parsed.rotation, + uses_ccm_records: catalog_entry_uses_ccm_records(entry), + supported_for_diagnosis: parsed.rotation_supported, + }; + } + + let logical_name = lower_camel_identifier(parsed.basename); + SccmSourceCatalogEntry { + basename: format!("{}.log", parsed.basename), + family: SccmArtifactFamily::Unknown(logical_name.clone()), + logical_name, + role, + rotation: parsed.rotation, + uses_ccm_records: false, + supported_for_diagnosis: false, + } +} + +pub fn declared_source_catalog() -> Vec { + let mut declared = Vec::with_capacity(SOURCE_CATALOG.len()); + + for entry in SOURCE_CATALOG { + declared.push(declared_catalog_entry(entry, entry.role.clone())); + } + + declared +} + +fn declared_catalog_entry(entry: &CatalogSpec, role: SccmRole) -> SccmSourceCatalogEntry { + SccmSourceCatalogEntry { + basename: format!("{}.log", entry.basename), + logical_name: entry.logical_name.to_string(), + role, + family: entry.family.clone(), + rotation: SccmRotation::Current, + uses_ccm_records: catalog_entry_uses_ccm_records(entry), + supported_for_diagnosis: true, + } +} + +fn catalog_entry_uses_ccm_records(entry: &CatalogSpec) -> bool { + !matches!( + entry.logical_name, + "clientMsi" | "reportingEvents" | "componentBasedServicing" + ) +} + +struct ParsedArtifactName<'a> { + basename: &'a str, + rotation: SccmRotation, + rotation_supported: bool, +} + +impl<'a> ParsedArtifactName<'a> { + fn from_name(name: &'a str) -> Self { + let lowercase = name.to_ascii_lowercase(); + + if lowercase.ends_with(".log") { + return Self { + basename: &name[..name.len() - ".log".len()], + rotation: SccmRotation::Current, + rotation_supported: true, + }; + } + + if let Some(separator) = lowercase.rfind(".log.") { + let suffix = &name[separator + ".log.".len()..]; + let rotation = if let Some(number) = parse_canonical_rotation_number(suffix) { + Some(SccmRotation::Numbered(number)) + } else if is_canonical_rotation_timestamp(suffix) { + Some(SccmRotation::Timestamped(suffix.to_string())) + } else { + None + }; + + if let Some(rotation) = rotation { + return Self { + basename: &name[..separator], + rotation, + rotation_supported: true, + }; + } + + return Self { + basename: &name[..separator], + rotation: unknown_filename_suffix(&name[separator + ".log".len()..]), + rotation_supported: false, + }; + } + + if lowercase.ends_with(".lo_") { + return Self { + basename: &name[..name.len() - ".lo_".len()], + rotation: SccmRotation::LoUnderscore, + rotation_supported: true, + }; + } + + if let Some(separator) = name.rfind('.') { + return Self { + basename: &name[..separator], + rotation: unknown_filename_suffix(&name[separator..]), + rotation_supported: false, + }; + } + + Self { + basename: name, + rotation: unknown_filename_suffix(""), + rotation_supported: false, + } + } +} + +fn unknown_filename_suffix(raw_suffix: &str) -> SccmRotation { + SccmRotation::Unknown(SccmUnknownRotation { + kind: "filenameSuffix".to_string(), + value: Some(Value::String(raw_suffix.to_string())), + }) +} + +fn lower_camel_identifier(value: &str) -> String { + let mut words = value + .split(|character: char| !character.is_alphanumeric()) + .filter(|word| !word.is_empty()); + let Some(first) = words.next() else { + return "unknown".to_string(); + }; + + let mut result = lower_leading_initialism(first); + for word in words { + let mut characters = word.chars(); + if let Some(first) = characters.next() { + result.extend(first.to_uppercase()); + result.extend(characters); + } + } + result +} + +fn lower_leading_initialism(word: &str) -> String { + let characters: Vec = word.chars().collect(); + let uppercase_run = characters + .iter() + .take_while(|character| character.is_uppercase()) + .count(); + let lowercase_count = if uppercase_run > 1 + && uppercase_run < characters.len() + && characters[uppercase_run].is_lowercase() + { + uppercase_run - 1 + } else { + uppercase_run + }; + + let mut result = String::new(); + for character in &characters[..lowercase_count] { + result.extend(character.to_lowercase()); + } + for character in &characters[lowercase_count..] { + result.push(*character); + } + result +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs new file mode 100644 index 000000000..99d58fb16 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -0,0 +1,1090 @@ +//! Sealing of canonical SCCM client evidence behind an opaque capability. +//! +//! Raw payload bytes exist only while this constructor verifies their digest +//! and normalizes their CCM logical records. Reducers receive the resulting +//! immutable-by-API capability, never bytes or caller-supplied evidence. + +// The public facade lands before workflow reducers, while its capability +// accessors deliberately remain crate-private. No production reducer owns +// them in this slice, so rustc cannot yet observe those call sites; retaining +// the local lint allowance avoids weakening workspace-wide warning policy. +#![allow(dead_code)] + +use std::{ + collections::{BTreeMap, BTreeSet}, + io::{self, Write}, +}; + +use encoding_rs::{UTF_16BE, UTF_16LE, UTF_8, WINDOWS_1252}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::parser::ccm::scan_logical_records_bounded; +use crate::sccm::catalog::classify_artifact_name; +use crate::sccm::evidence::SccmRawEvidenceSnapshot; +use crate::sccm::{ + extract_admitted_keys, SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, + SccmExtractionProfile, SccmExtractionProfileMaturity, SccmKeyExtractionResult, SccmRole, + SccmRotation, SccmTimeOrderingState, +}; + +use super::{ + assess_client_intake, + intake::{ + is_safe_artifact_id, is_supported_encoding, source_matches_group, + task_sequence_path_class_for_relative_path, + }, + SccmClientIntakeAssessment, SccmClientIntakeBundle, SccmClientIntakeError, + SccmClientIntakeFragment, SccmTaskSequencePathClass, MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, + TASK_SEQUENCE_TEST_PROFILE_ID, TASK_SEQUENCE_TEST_VERSION, +}; + +/// Maximum raw bytes decoded from one client payload at the parser admission +/// boundary. This is intentionally below the native physical-file cap: the +/// pure parser must remain safe for wasm and non-native callers too. +pub(crate) const MAX_SCCM_CLIENT_ADMISSION_PAYLOAD_BYTES: usize = 4 * 1024 * 1024; +/// Maximum raw bytes admitted across one client evidence bundle. +pub(crate) const MAX_SCCM_CLIENT_ADMISSION_TOTAL_PAYLOAD_BYTES: usize = 16 * 1024 * 1024; +/// Maximum logical CCM records retained from one admitted client bundle. +pub(crate) const MAX_SCCM_CLIENT_ADMISSION_LOGICAL_RECORDS: usize = 4_096; +/// Maximum projected evidence bytes retained for one admitted client bundle. +pub(crate) const MAX_SCCM_CLIENT_ADMISSION_RETAINED_EVIDENCE_BYTES: usize = 3 * 1024 * 1024; +/// Maximum bytes streamed through the evidence/profile projection of the +/// deterministic client evidence integrity seal. +pub(crate) const MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES: usize = 4 * 1024 * 1024; +/// Independent bound for the compact source-authority projection. Together +/// these two explicit sub-bounds cap aggregate integrity hashing work. +pub(crate) const MAX_SCCM_CLIENT_ADMISSION_SOURCE_SEAL_BYTES: usize = 2 * 1024 * 1024; +pub(crate) const MAX_SCCM_CLIENT_ADMISSION_AGGREGATE_SEAL_BYTES: usize = + MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES + MAX_SCCM_CLIENT_ADMISSION_SOURCE_SEAL_BYTES; + +/// Raw, already-captured bytes offered to the one-shot client evidence +/// admission boundary. This is an input only: the successful capability does +/// not retain this vector or any decoded raw text. +pub struct SccmClientCapturedPayload { + artifact_id: String, + bytes: Vec, +} + +impl SccmClientCapturedPayload { + /// Constructs one bytes-only admission input. Artifact identity is only a + /// routing handle; byte length and digest authority remain exclusively on + /// the canonical intake fragment. + pub fn new( + artifact_id: impl Into, + bytes: Vec, + ) -> Result { + let artifact_id = artifact_id.into(); + if !is_safe_artifact_id(&artifact_id) { + return Err(SccmClientEvidenceAdmissionError::InvalidPayloadArtifactId); + } + if bytes.len() > MAX_SCCM_CLIENT_ADMISSION_PAYLOAD_BYTES { + return Err(SccmClientEvidenceAdmissionError::PayloadByteLimitExceeded); + } + Ok(Self { artifact_id, bytes }) + } +} + +/// The bounded evidence authority internal client reducers consume. +/// +/// Its fields stay private and it deliberately implements neither serde nor a +/// public constructor. `verify_integrity` recomputes the deterministic seal so +/// a reducer can fail closed if future crate-internal code corrupts it. +pub struct SccmClientAdmittedEvidence { + evidence: Vec, + source_coverage: BTreeMap, + source_coverage_by_basename: BTreeMap, + source_basename_by_artifact: BTreeMap, + source_artifacts: BTreeMap, + unavailable_source_basenames: BTreeSet, + admitted_source_groups: BTreeSet, + profiles_by_artifact: BTreeMap, + task_sequence_sources: BTreeMap, + integrity_seal: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct SccmClientAdmittedSourceArtifact { + pub(crate) basename: String, + pub(crate) rotation: SccmRotation, + pub(crate) coverage: SccmCoverageState, + pub(crate) fragment_complete: Option, + pub(crate) physical: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct SccmClientAdmittedTaskSequenceSource { + pub(crate) path_class: SccmTaskSequencePathClass, + pub(crate) rotation: SccmRotation, + pub(crate) coverage: SccmCoverageState, + pub(crate) fragment_complete: Option, + pub(crate) physical_evidence: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct SccmClientAdmittedTaskSequencePhysicalEvidence { + pub(crate) line_start: u32, + pub(crate) line_end: u32, + pub(crate) key_candidate: bool, +} + +pub(crate) struct SccmClientAdmittedTaskSequenceEvidence<'a> { + pub(crate) evidence: &'a [SccmEvidence], + pub(crate) sources: &'a BTreeMap, + pub(crate) profiles: &'a BTreeMap, + pub(crate) coverage: Option<&'a SccmCoverageState>, +} + +/// Artifact-scoped key-extraction results selected only from sealed client +/// evidence authority. Its private fields and lack of a constructor prevent a +/// generic extraction result from being substituted for admitted authority. +pub(crate) struct SccmClientAdmittedKeyExtraction { + artifact_id: String, + artifact_family: SccmArtifactFamily, + results: Vec, +} + +impl SccmClientAdmittedKeyExtraction { + pub(crate) fn artifact_id(&self) -> &str { + &self.artifact_id + } + + pub(crate) fn artifact_family(&self) -> &SccmArtifactFamily { + &self.artifact_family + } + + pub(crate) fn results(&self) -> &[SccmKeyExtractionResult] { + &self.results + } +} + +impl SccmClientAdmittedEvidence { + pub(crate) fn evidence(&self) -> Result<&[SccmEvidence], SccmClientEvidenceAdmissionError> { + self.verify_integrity()?; + Ok(&self.evidence) + } + + pub(crate) fn source_coverage( + &self, + logical_artifact_id: &str, + ) -> Result, SccmClientEvidenceAdmissionError> { + self.verify_integrity()?; + Ok(self.source_coverage.get(logical_artifact_id)) + } + + pub(crate) fn source_coverage_for_basename( + &self, + basename: &str, + ) -> Result, SccmClientEvidenceAdmissionError> { + self.verify_integrity()?; + Ok(self.source_coverage_by_basename.get(basename)) + } + + pub(crate) fn task_sequence_evidence( + &self, + ) -> Result, SccmClientEvidenceAdmissionError> { + self.verify_integrity()?; + Ok(SccmClientAdmittedTaskSequenceEvidence { + evidence: &self.evidence, + sources: &self.task_sequence_sources, + profiles: &self.profiles_by_artifact, + coverage: self.source_coverage.get("client-task-sequence-smsts"), + }) + } + + pub(crate) fn source_basename_for_artifact( + &self, + artifact_id: &str, + ) -> Result, SccmClientEvidenceAdmissionError> { + self.verify_integrity()?; + Ok(self + .source_basename_by_artifact + .get(artifact_id) + .map(String::as_str)) + } + + pub(crate) fn source_artifacts( + &self, + ) -> Result<&BTreeMap, SccmClientEvidenceAdmissionError> + { + self.verify_integrity()?; + Ok(&self.source_artifacts) + } + + pub(crate) fn source_basename_is_complete( + &self, + basename: &str, + ) -> Result { + self.verify_integrity()?; + Ok(self.source_coverage_by_basename.contains_key(basename) + && !self.unavailable_source_basenames.contains(basename)) + } + + pub(crate) fn extract_keys_for_artifact( + &self, + artifact_id: &str, + ) -> Result { + self.verify_integrity()?; + let (sealed_artifact_id, profile) = self + .profiles_by_artifact + .get_key_value(artifact_id) + .ok_or(SccmClientEvidenceAdmissionError::MissingAdmittedExtractionProfile)?; + let [artifact_family] = profile.validated_artifact_families.as_slice() else { + return Err(SccmClientEvidenceAdmissionError::IntegrityViolation); + }; + let mut extraction_profile = profile.clone(); + if (matches!(artifact_family, SccmArtifactFamily::ClientPolicy) + && profile.maturity == SccmExtractionProfileMaturity::Experimental) + || matches!( + artifact_family, + SccmArtifactFamily::ClientSetup + | SccmArtifactFamily::ClientHealth + | SccmArtifactFamily::ClientIdentity + | SccmArtifactFamily::ClientLocation + ) + { + extraction_profile.validated_artifact_families.clear(); + } + let results = self + .evidence + .iter() + .filter(|evidence| evidence.reference.artifact_id == *sealed_artifact_id) + .map(|evidence| extract_admitted_keys(evidence, &extraction_profile)) + .collect::>(); + if results.is_empty() { + return Err(SccmClientEvidenceAdmissionError::MissingAdmittedArtifactEvidence); + } + + Ok(SccmClientAdmittedKeyExtraction { + artifact_id: sealed_artifact_id.clone(), + artifact_family: artifact_family.clone(), + results, + }) + } + + pub(crate) fn integrity_seal(&self) -> &str { + &self.integrity_seal + } + + pub(crate) fn verify_integrity(&self) -> Result<(), SccmClientEvidenceAdmissionError> { + let recomputed = compute_integrity_seal( + &self.evidence, + IntegrityAuthority { + source_coverage: &self.source_coverage, + source_coverage_by_basename: &self.source_coverage_by_basename, + source_basename_by_artifact: &self.source_basename_by_artifact, + source_artifacts: &self.source_artifacts, + unavailable_source_basenames: &self.unavailable_source_basenames, + admitted_source_groups: &self.admitted_source_groups, + profiles_by_artifact: &self.profiles_by_artifact, + task_sequence_sources: &self.task_sequence_sources, + }, + )?; + (recomputed == self.integrity_seal) + .then_some(()) + .ok_or(SccmClientEvidenceAdmissionError::IntegrityViolation) + } + + /// Makes workflow handling of a missing, capped, malformed, or partial + /// source explicit. The authority never turns a coverage gap into success. + pub(crate) fn require_captured_source( + &self, + logical_artifact_id: &str, + ) -> Result<(), SccmClientEvidenceAdmissionError> { + match self.source_coverage(logical_artifact_id)? { + Some(_) if self.admitted_source_groups.contains(logical_artifact_id) => Ok(()), + Some(_) => Err(SccmClientEvidenceAdmissionError::SourceCoverageUnavailable), + None => Err(SccmClientEvidenceAdmissionError::UnknownSourceGroup), + } + } + + #[cfg(test)] + pub(crate) fn test_only_mutate_first_message(&mut self) { + self.evidence[0].message.push_str(" forged"); + } + + #[cfg(test)] + pub(crate) fn test_only_mutate_first_profile(&mut self) { + self.profiles_by_artifact + .values_mut() + .next() + .expect("test admission has one selected profile") + .profile_id + .push_str("-forged"); + } + + #[cfg(test)] + pub(crate) fn test_only_mutate_first_source_authority(&mut self) { + let source = self + .source_artifacts + .values_mut() + .next() + .expect("test admission has one source authority"); + source.physical = !source.physical; + } + + #[cfg(test)] + pub(crate) fn test_only_duplicate_first_evidence(&mut self) { + self.evidence.push(self.evidence[0].clone()); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SccmClientEvidenceAdmissionError { + #[error("client evidence admission bundle is invalid: {0}")] + InvalidBundle(SccmClientIntakeError), + #[error("client evidence admission assessment is not the canonical bundle projection")] + AssessmentMutation, + #[error("client evidence admission payload count exceeds the v1 bound")] + PayloadLimitExceeded, + #[error("client evidence admission payload exceeds the v1 per-payload byte cap")] + PayloadByteLimitExceeded, + #[error("client evidence admission aggregate payload bytes exceed the v1 cap")] + AggregatePayloadByteLimitExceeded, + #[error("client evidence admission payload artifact ID is invalid")] + InvalidPayloadArtifactId, + #[error("client evidence admission contains duplicate payload artifact IDs")] + DuplicatePayload, + #[error("client evidence admission is missing a payload for a captured fragment")] + MissingPayload, + #[error("client evidence admission payload has no matching canonical captured fragment")] + ExtraPayload, + #[error("client evidence admission payload length or digest is invalid")] + PayloadIntegrityMismatch, + #[error("client evidence admission captured fragment has no intake-bound length and digest")] + MissingContentBinding, + #[error("client evidence admission cannot decode the declared artifact encoding")] + InvalidEncoding, + #[error("client evidence admission payload has no complete CCM logical record")] + MalformedCcm, + #[error("client evidence admission CCM framing is incomplete or ambiguous")] + IncompleteCcmFraming, + #[error("client evidence admission logical record count exceeds the v1 cap")] + LogicalRecordLimitExceeded, + #[error("client evidence admission retained evidence exceeds the v1 byte cap")] + RetainedEvidenceLimitExceeded, + #[error("client evidence admission integrity seal exceeds the v1 byte cap")] + IntegritySealLimitExceeded, + #[error("client evidence admission record timestamp provenance is not comparable")] + InvalidTimestampProvenance, + #[error("client evidence admission has no sealed extraction profile for the artifact")] + MissingAdmittedExtractionProfile, + #[error("client evidence admission has no sealed evidence for the artifact")] + MissingAdmittedArtifactEvidence, + #[error("client evidence admission produced colliding logical evidence identities")] + CollidingEvidenceIdentity, + #[error("client evidence admission integrity seal is invalid")] + IntegrityViolation, + #[error("client evidence admission source group is not declared")] + UnknownSourceGroup, + #[error("client evidence admission source coverage is not complete captured evidence")] + SourceCoverageUnavailable, +} + +/// Reassesses a canonical client bundle and seals the logical CCM evidence it +/// derives from each exact complete captured payload. +pub fn admit_client_evidence( + bundle: &SccmClientIntakeBundle, + assessment: &SccmClientIntakeAssessment, + payloads: &[SccmClientCapturedPayload], +) -> Result { + if payloads.len() > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS { + return Err(SccmClientEvidenceAdmissionError::PayloadLimitExceeded); + } + validate_payload_budget(payloads)?; + + let canonical = + assess_client_intake(bundle).map_err(SccmClientEvidenceAdmissionError::InvalidBundle)?; + if canonical != *assessment { + return Err(SccmClientEvidenceAdmissionError::AssessmentMutation); + } + + let source_coverage = canonical + .groups + .iter() + .map(|group| (group.logical_artifact_id.clone(), group.coverage.clone())) + .collect::>(); + let mut source_coverage_by_basename = BTreeMap::new(); + for fragment in canonical.groups.iter().flat_map(|group| &group.fragments) { + let canonical_basename = + classify_artifact_name(&fragment.basename, SccmRole::Client).basename; + source_coverage_by_basename + .entry(canonical_basename) + .and_modify(|coverage| { + if source_coverage_priority(&fragment.coverage) > source_coverage_priority(coverage) + { + *coverage = fragment.coverage.clone(); + } + }) + .or_insert_with(|| fragment.coverage.clone()); + } + for capture_gap in &canonical.capture_gaps { + let canonical_basename = + classify_artifact_name(&capture_gap.basename, SccmRole::Client).basename; + source_coverage_by_basename + .entry(canonical_basename) + .and_modify(|coverage| { + if source_coverage_priority(&capture_gap.coverage) + > source_coverage_priority(coverage) + { + *coverage = capture_gap.coverage.clone(); + } + }) + .or_insert_with(|| capture_gap.coverage.clone()); + } + let mut eligible = BTreeMap::new(); + let mut source_artifacts = BTreeMap::new(); + for fragment in canonical.groups.iter().flat_map(|group| &group.fragments) { + if !is_supported_raw_ccm_source(&fragment.basename) { + continue; + } + source_artifacts + .entry(fragment.artifact_id.clone()) + .or_insert_with(|| SccmClientAdmittedSourceArtifact { + basename: fragment.basename.clone(), + rotation: fragment.rotation.clone(), + coverage: fragment.coverage.clone(), + fragment_complete: fragment.fragment_complete, + physical: fragment.relative_path.is_some(), + }); + } + for gap in &canonical.capture_gaps { + source_artifacts.insert( + gap.artifact_id.clone(), + SccmClientAdmittedSourceArtifact { + basename: gap.basename.clone(), + rotation: gap.rotation.clone(), + coverage: gap.coverage.clone(), + fragment_complete: Some(false), + physical: false, + }, + ); + } + let mut source_basename_by_artifact = BTreeMap::new(); + let mut unavailable_source_basenames = canonical + .capture_gaps + .iter() + .filter(|gap| is_supported_diagnostic_source(&gap.basename)) + .map(|gap| classify_artifact_name(&gap.basename, SccmRole::Client).basename) + .collect::>(); + unavailable_source_basenames.extend( + canonical + .groups + .iter() + .flat_map(|group| &group.fragments) + .filter(|fragment| { + is_supported_diagnostic_source(&fragment.basename) + && (fragment.coverage != SccmCoverageState::Captured + || fragment.fragment_complete != Some(true)) + }) + .map(|fragment| classify_artifact_name(&fragment.basename, SccmRole::Client).basename), + ); + let mut unbound_complete_captures = BTreeSet::new(); + for fragment in &canonical.physical_artifacts { + let classified = classify_artifact_name(&fragment.basename, SccmRole::Client); + if !classified.supported_for_diagnosis || !classified.uses_ccm_records { + continue; + } + if fragment.coverage != SccmCoverageState::Captured { + unavailable_source_basenames.insert(classified.basename.clone()); + continue; + } + if fragment.fragment_complete != Some(true) { + unavailable_source_basenames.insert(classified.basename.clone()); + if matches!(classified.family, SccmArtifactFamily::ClientTaskSequence) + && fragment.declared_byte_length.is_some() + && fragment.content_sha256.is_some() + && has_supported_payload_encoding(fragment) + { + eligible.insert(fragment.artifact_id.clone(), (fragment, classified.family)); + } + continue; + } + if fragment.declared_byte_length.is_none() || fragment.content_sha256.is_none() { + unbound_complete_captures.insert(fragment.artifact_id.as_str()); + unavailable_source_basenames.insert(classified.basename.clone()); + continue; + } + if !has_supported_payload_encoding(fragment) { + unavailable_source_basenames.insert(classified.basename.clone()); + continue; + } + source_basename_by_artifact.insert(fragment.artifact_id.clone(), classified.basename); + eligible.insert(fragment.artifact_id.clone(), (fragment, classified.family)); + } + let admitted_source_groups = canonical + .groups + .iter() + .filter(|group| { + group.fragments.iter().any(is_supported_raw_ccm_fragment) + && group + .fragments + .iter() + .filter(|fragment| is_supported_raw_ccm_fragment(fragment)) + .all(is_bound_complete_capture) + && !canonical.capture_gaps.iter().any(|capture_gap| { + is_supported_raw_ccm_source(&capture_gap.basename) + && source_matches_group( + &capture_gap.basename, + &capture_gap.rotation, + &group.logical_artifact_id, + ) + }) + }) + .map(|group| group.logical_artifact_id.clone()) + .collect::>(); + let mut task_sequence_sources = canonical + .groups + .iter() + .find(|group| group.logical_artifact_id == "client-task-sequence-smsts") + .into_iter() + .flat_map(|group| &group.fragments) + .map(|fragment| { + let path_class = fragment + .relative_path + .as_deref() + .and_then(task_sequence_path_class_for_relative_path) + .unwrap_or(SccmTaskSequencePathClass::Unknown); + ( + fragment.artifact_id.clone(), + SccmClientAdmittedTaskSequenceSource { + path_class, + rotation: fragment.rotation.clone(), + coverage: fragment.coverage.clone(), + fragment_complete: fragment.fragment_complete, + physical_evidence: None, + }, + ) + }) + .collect::>(); + if payloads + .iter() + .any(|payload| unbound_complete_captures.contains(payload.artifact_id.as_str())) + { + return Err(SccmClientEvidenceAdmissionError::MissingContentBinding); + } + if payloads.len() < eligible.len() { + return Err(SccmClientEvidenceAdmissionError::MissingPayload); + } + if payloads.len() > eligible.len() { + return Err(SccmClientEvidenceAdmissionError::ExtraPayload); + } + + let mut ordered_payloads = payloads.iter().collect::>(); + ordered_payloads.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + let mut seen_payload_ids = BTreeSet::new(); + let mut evidence = Vec::new(); + let mut profiles_by_artifact = BTreeMap::new(); + let mut evidence_ids = BTreeSet::new(); + let mut evidence_references = BTreeSet::new(); + let mut retained_evidence_bytes = 0usize; + let mut remaining_logical_records = MAX_SCCM_CLIENT_ADMISSION_LOGICAL_RECORDS; + + for payload in ordered_payloads { + if !seen_payload_ids.insert(payload.artifact_id.as_str()) { + return Err(SccmClientEvidenceAdmissionError::DuplicatePayload); + } + let (fragment, family) = eligible + .get(&payload.artifact_id) + .ok_or(SccmClientEvidenceAdmissionError::ExtraPayload)?; + let fragment = *fragment; + validate_payload(payload, fragment)?; + + let profile = admission_profile(fragment.configmgr_version.as_deref(), family); + let content = decode_payload(payload, fragment.encoding.as_deref())?; + if fragment.fragment_complete != Some(true) { + let line_count = content.lines().count(); + if line_count == 0 { + return Err(SccmClientEvidenceAdmissionError::MalformedCcm); + } + let line_end = u32::try_from(line_count) + .map_err(|_| SccmClientEvidenceAdmissionError::LogicalRecordLimitExceeded)?; + let source = task_sequence_sources + .get_mut(&fragment.artifact_id) + .ok_or(SccmClientEvidenceAdmissionError::IntegrityViolation)?; + source.physical_evidence = Some(SccmClientAdmittedTaskSequencePhysicalEvidence { + line_start: 1, + line_end, + key_candidate: task_sequence_key_candidate(&content), + }); + profiles_by_artifact.insert(fragment.artifact_id.clone(), profile); + continue; + } + let artifact = artifact_for_fragment(fragment); + let scan = + scan_logical_records_bounded(&content, &fragment.basename, remaining_logical_records); + if scan.record_limit_exceeded { + return Err(SccmClientEvidenceAdmissionError::LogicalRecordLimitExceeded); + } + if !scan.complete { + return Err(SccmClientEvidenceAdmissionError::IncompleteCcmFraming); + } + if scan.records.is_empty() { + return Err(SccmClientEvidenceAdmissionError::MalformedCcm); + } + remaining_logical_records = remaining_logical_records + .checked_sub(scan.records.len()) + .ok_or(SccmClientEvidenceAdmissionError::LogicalRecordLimitExceeded)?; + + for record in scan.records { + let normalized = SccmRawEvidenceSnapshot::from_record(&artifact, record).export(); + if normalized.evidence_id != normalized.reference.entry_id + || normalized.reference.line_start.is_none() + || normalized.reference.line_end.is_none() + || !has_consistent_timestamp_provenance(&normalized) + { + return Err(SccmClientEvidenceAdmissionError::InvalidTimestampProvenance); + } + let reference_identity = ( + normalized.reference.artifact_id.clone(), + normalized.reference.entry_id.clone(), + normalized.reference.line_start, + normalized.reference.line_end, + ); + if !evidence_ids.insert(normalized.evidence_id.clone()) + || !evidence_references.insert(reference_identity) + { + return Err(SccmClientEvidenceAdmissionError::CollidingEvidenceIdentity); + } + retained_evidence_bytes = retained_evidence_bytes + .checked_add(retained_evidence_size(&normalized)?) + .ok_or(SccmClientEvidenceAdmissionError::RetainedEvidenceLimitExceeded)?; + if retained_evidence_bytes > MAX_SCCM_CLIENT_ADMISSION_RETAINED_EVIDENCE_BYTES { + return Err(SccmClientEvidenceAdmissionError::RetainedEvidenceLimitExceeded); + } + evidence.push(normalized); + } + profiles_by_artifact.insert(fragment.artifact_id.clone(), profile); + } + + evidence.sort_by(compare_evidence); + let integrity_seal = compute_integrity_seal( + &evidence, + IntegrityAuthority { + source_coverage: &source_coverage, + source_coverage_by_basename: &source_coverage_by_basename, + source_basename_by_artifact: &source_basename_by_artifact, + source_artifacts: &source_artifacts, + unavailable_source_basenames: &unavailable_source_basenames, + admitted_source_groups: &admitted_source_groups, + profiles_by_artifact: &profiles_by_artifact, + task_sequence_sources: &task_sequence_sources, + }, + )?; + Ok(SccmClientAdmittedEvidence { + evidence, + source_coverage, + source_coverage_by_basename, + source_basename_by_artifact, + source_artifacts, + unavailable_source_basenames, + admitted_source_groups, + profiles_by_artifact, + task_sequence_sources, + integrity_seal, + }) +} + +fn has_consistent_timestamp_provenance(evidence: &SccmEvidence) -> bool { + match evidence.timestamp.ordering_state { + SccmTimeOrderingState::NormalizedUtc => { + evidence.timestamp.original_display.is_some() + && evidence.timestamp.offset_minutes.is_some() + && evidence.timestamp.utc_millis.is_some() + } + SccmTimeOrderingState::OffsetMissing | SccmTimeOrderingState::OffsetInvalid => { + evidence.timestamp.original_display.is_some() && evidence.timestamp.utc_millis.is_none() + } + SccmTimeOrderingState::TimestampMissing => { + evidence.timestamp.utc_millis.is_none() && evidence.timestamp.offset_minutes.is_none() + } + } +} + +fn task_sequence_key_candidate(content: &str) -> bool { + [ + "executionId", + "taskSequencePackageId", + "advertisementId", + "runContext", + ] + .iter() + .all(|label| { + content.split_ascii_whitespace().any(|token| { + token.split_once('=').is_some_and(|(candidate, value)| { + candidate.eq_ignore_ascii_case(label) && !value.is_empty() + }) + }) + }) +} + +fn admission_profile( + configmgr_version: Option<&str>, + family: &SccmArtifactFamily, +) -> SccmExtractionProfile { + if !matches!(family, SccmArtifactFamily::ClientTaskSequence) { + return SccmExtractionProfile::for_artifact_family(configmgr_version, family); + } + + let mut profile = SccmExtractionProfile::for_version(configmgr_version); + profile.validated_artifact_families = vec![family.clone()]; + if configmgr_version == Some(TASK_SEQUENCE_TEST_VERSION) { + profile.profile_id = TASK_SEQUENCE_TEST_PROFILE_ID.to_owned(); + profile.configmgr_version_prefixes = vec![TASK_SEQUENCE_TEST_VERSION.to_owned()]; + profile.maturity = SccmExtractionProfileMaturity::Experimental; + } + profile +} + +fn validate_payload_budget( + payloads: &[SccmClientCapturedPayload], +) -> Result<(), SccmClientEvidenceAdmissionError> { + let mut total_payload_bytes = 0usize; + for payload in payloads { + if payload.bytes.len() > MAX_SCCM_CLIENT_ADMISSION_PAYLOAD_BYTES { + return Err(SccmClientEvidenceAdmissionError::PayloadByteLimitExceeded); + } + total_payload_bytes = total_payload_bytes + .checked_add(payload.bytes.len()) + .ok_or(SccmClientEvidenceAdmissionError::AggregatePayloadByteLimitExceeded)?; + if total_payload_bytes > MAX_SCCM_CLIENT_ADMISSION_TOTAL_PAYLOAD_BYTES { + return Err(SccmClientEvidenceAdmissionError::AggregatePayloadByteLimitExceeded); + } + } + Ok(()) +} + +fn is_supported_raw_ccm_fragment(fragment: &SccmClientIntakeFragment) -> bool { + is_supported_raw_ccm_source(&fragment.basename) +} + +fn is_supported_raw_ccm_source(basename: &str) -> bool { + let classified = classify_artifact_name(basename, SccmRole::Client); + classified.supported_for_diagnosis && classified.uses_ccm_records +} + +fn is_supported_diagnostic_source(basename: &str) -> bool { + classify_artifact_name(basename, SccmRole::Client).supported_for_diagnosis +} + +fn is_bound_complete_capture(fragment: &SccmClientIntakeFragment) -> bool { + fragment.coverage == SccmCoverageState::Captured + && fragment.fragment_complete == Some(true) + && fragment.declared_byte_length.is_some() + && fragment.content_sha256.is_some() + && has_supported_payload_encoding(fragment) +} + +fn has_supported_payload_encoding(fragment: &SccmClientIntakeFragment) -> bool { + fragment + .encoding + .as_deref() + .is_some_and(is_supported_encoding) +} + +fn validate_payload( + payload: &SccmClientCapturedPayload, + fragment: &SccmClientIntakeFragment, +) -> Result<(), SccmClientEvidenceAdmissionError> { + let length = u64::try_from(payload.bytes.len()) + .map_err(|_| SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch)?; + let declared_length = fragment + .declared_byte_length + .ok_or(SccmClientEvidenceAdmissionError::MissingContentBinding)?; + let declared_digest = fragment + .content_sha256 + .as_deref() + .ok_or(SccmClientEvidenceAdmissionError::MissingContentBinding)?; + if length != declared_length || !is_lowercase_sha256(declared_digest) { + return Err(SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch); + } + (digest_hex(&payload.bytes) == declared_digest) + .then_some(()) + .ok_or(SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch) +} + +fn retained_evidence_size( + evidence: &SccmEvidence, +) -> Result { + let execution_context_bytes = match evidence.execution_context.as_ref() { + Some(handle) => handle + .scheme + .len() + .checked_add(handle.value.len()) + .ok_or(SccmClientEvidenceAdmissionError::RetainedEvidenceLimitExceeded)?, + None => 0, + }; + let mut total = 0usize; + for size in [ + evidence.evidence_id.len(), + evidence.reference.artifact_id.len(), + evidence.reference.entry_id.len(), + evidence.component.as_deref().map_or(0, str::len), + evidence.ccm_source_file.as_deref().map_or(0, str::len), + evidence.message.len(), + evidence + .timestamp + .original_display + .as_deref() + .map_or(0, str::len), + execution_context_bytes, + // Reserve fixed-field and container overhead so the retained-memory + // cap remains conservative without serializing a second copy. + 256, + ] { + total = total + .checked_add(size) + .ok_or(SccmClientEvidenceAdmissionError::RetainedEvidenceLimitExceeded)?; + } + Ok(total) +} + +fn decode_payload( + payload: &SccmClientCapturedPayload, + encoding: Option<&str>, +) -> Result { + let (encoding, declared_bom) = match encoding { + Some("utf-8") => (UTF_8, Some(UnicodeBom::Utf8)), + Some("utf-16le") => (UTF_16LE, Some(UnicodeBom::Utf16Le)), + Some("utf-16be") => (UTF_16BE, Some(UnicodeBom::Utf16Be)), + Some("windows-1252") => (WINDOWS_1252, None), + _ => return Err(SccmClientEvidenceAdmissionError::InvalidEncoding), + }; + let bytes = match recognized_unicode_bom(&payload.bytes) { + Some((actual_bom, bom_len)) if Some(actual_bom) == declared_bom => { + &payload.bytes[bom_len..] + } + Some(_) => return Err(SccmClientEvidenceAdmissionError::InvalidEncoding), + None => payload.bytes.as_slice(), + }; + let (decoded, had_errors) = encoding.decode_without_bom_handling(bytes); + (!had_errors) + .then_some(decoded.into_owned()) + .ok_or(SccmClientEvidenceAdmissionError::InvalidEncoding) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum UnicodeBom { + Utf8, + Utf16Le, + Utf16Be, + Utf32Le, + Utf32Be, +} + +fn recognized_unicode_bom(bytes: &[u8]) -> Option<(UnicodeBom, usize)> { + if bytes.starts_with(&[0xff, 0xfe, 0x00, 0x00]) { + Some((UnicodeBom::Utf32Le, 4)) + } else if bytes.starts_with(&[0x00, 0x00, 0xfe, 0xff]) { + Some((UnicodeBom::Utf32Be, 4)) + } else if bytes.starts_with(&[0xef, 0xbb, 0xbf]) { + Some((UnicodeBom::Utf8, 3)) + } else if bytes.starts_with(&[0xff, 0xfe]) { + Some((UnicodeBom::Utf16Le, 2)) + } else if bytes.starts_with(&[0xfe, 0xff]) { + Some((UnicodeBom::Utf16Be, 2)) + } else { + None + } +} + +fn artifact_for_fragment(fragment: &SccmClientIntakeFragment) -> SccmArtifact { + SccmArtifact { + artifact_id: fragment.artifact_id.clone(), + display_name: fragment.basename.clone(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: fragment.configmgr_version.clone(), + collected_at_utc: fragment.collected_at_utc.clone(), + rotation: fragment.rotation.clone(), + coverage: fragment.coverage.clone(), + encoding: fragment.encoding.clone(), + } +} + +fn compare_evidence(left: &SccmEvidence, right: &SccmEvidence) -> std::cmp::Ordering { + ( + left.reference.artifact_id.as_str(), + left.reference.line_start, + left.reference.line_end, + left.reference.entry_id.as_str(), + left.evidence_id.as_str(), + ) + .cmp(&( + right.reference.artifact_id.as_str(), + right.reference.line_start, + right.reference.line_end, + right.reference.entry_id.as_str(), + right.evidence_id.as_str(), + )) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IntegrityProjection<'a> { + evidence: &'a [SccmEvidence], + source_coverage: &'a BTreeMap, + source_coverage_by_basename: &'a BTreeMap, + source_basename_by_artifact: &'a BTreeMap, + source_artifacts_digest: &'a str, + unavailable_source_basenames: &'a BTreeSet, + admitted_source_groups: &'a BTreeSet, + profile_assignments: &'a BTreeMap<&'a str, usize>, + profiles: &'a [&'a SccmExtractionProfile], + task_sequence_sources: &'a BTreeMap, +} + +struct BoundedIntegrityWriter { + hasher: Sha256, + byte_limit: usize, + bytes_written: usize, + limit_exceeded: bool, +} + +impl BoundedIntegrityWriter { + fn new(byte_limit: usize) -> Self { + Self { + hasher: Sha256::new(), + byte_limit, + bytes_written: 0, + limit_exceeded: false, + } + } + + fn finish(self) -> String { + self.hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } +} + +impl Write for BoundedIntegrityWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let Some(next_size) = self.bytes_written.checked_add(bytes.len()) else { + self.limit_exceeded = true; + return Err(io::Error::other("integrity seal byte limit exceeded")); + }; + if next_size > self.byte_limit { + self.limit_exceeded = true; + return Err(io::Error::other("integrity seal byte limit exceeded")); + } + self.hasher.update(bytes); + self.bytes_written = next_size; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct IntegrityAuthority<'a> { + source_coverage: &'a BTreeMap, + source_coverage_by_basename: &'a BTreeMap, + source_basename_by_artifact: &'a BTreeMap, + source_artifacts: &'a BTreeMap, + unavailable_source_basenames: &'a BTreeSet, + admitted_source_groups: &'a BTreeSet, + profiles_by_artifact: &'a BTreeMap, + task_sequence_sources: &'a BTreeMap, +} + +fn compute_integrity_seal( + evidence: &[SccmEvidence], + authority: IntegrityAuthority<'_>, +) -> Result { + debug_assert_eq!( + MAX_SCCM_CLIENT_ADMISSION_AGGREGATE_SEAL_BYTES, + MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES + MAX_SCCM_CLIENT_ADMISSION_SOURCE_SEAL_BYTES + ); + // Many rotations legitimately select the same profile. Seal the complete + // profile once and bind each artifact to its deterministic index so the + // intake artifact ceiling does not multiply identical profile metadata + // past the independent seal cap. + let mut profile_indices = BTreeMap::::new(); + let mut unique_profiles = Vec::new(); + let mut profile_assignments = BTreeMap::new(); + for (artifact_id, profile) in authority.profiles_by_artifact { + let canonical_profile = serde_json::to_string(profile) + .map_err(|_| SccmClientEvidenceAdmissionError::IntegrityViolation)?; + let profile_index = match profile_indices.get(&canonical_profile) { + Some(index) => *index, + None => { + let index = unique_profiles.len(); + profile_indices.insert(canonical_profile, index); + unique_profiles.push(profile); + index + } + }; + profile_assignments.insert(artifact_id.as_str(), profile_index); + } + + // The complete source projection remains integrity-bound without copying + // repeated basenames and enum labels into the already bounded outer seal. + let mut source_writer = + BoundedIntegrityWriter::new(MAX_SCCM_CLIENT_ADMISSION_SOURCE_SEAL_BYTES); + let serialized_sources = serde_json::to_writer(&mut source_writer, authority.source_artifacts); + if serialized_sources.is_err() { + return Err(if source_writer.limit_exceeded { + SccmClientEvidenceAdmissionError::IntegritySealLimitExceeded + } else { + SccmClientEvidenceAdmissionError::IntegrityViolation + }); + } + let source_artifacts_digest = source_writer.finish(); + + let mut writer = BoundedIntegrityWriter::new(MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES); + let serialized = serde_json::to_writer( + &mut writer, + &IntegrityProjection { + evidence, + source_coverage: authority.source_coverage, + source_coverage_by_basename: authority.source_coverage_by_basename, + source_basename_by_artifact: authority.source_basename_by_artifact, + source_artifacts_digest: &source_artifacts_digest, + unavailable_source_basenames: authority.unavailable_source_basenames, + admitted_source_groups: authority.admitted_source_groups, + profile_assignments: &profile_assignments, + profiles: &unique_profiles, + task_sequence_sources: authority.task_sequence_sources, + }, + ); + if serialized.is_err() { + return Err(if writer.limit_exceeded { + SccmClientEvidenceAdmissionError::IntegritySealLimitExceeded + } else { + SccmClientEvidenceAdmissionError::IntegrityViolation + }); + } + Ok(writer.finish()) +} + +fn source_coverage_priority(coverage: &SccmCoverageState) -> u8 { + match coverage { + SccmCoverageState::Captured => 0, + SccmCoverageState::Absent => 1, + SccmCoverageState::Skipped => 2, + SccmCoverageState::Unsupported => 3, + SccmCoverageState::ParseFailed => 4, + SccmCoverageState::Capped => 5, + SccmCoverageState::AccessDenied => 6, + } +} + +fn digest_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn is_lowercase_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs new file mode 100644 index 000000000..598c37dba --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -0,0 +1,743 @@ +use sha2::{Digest, Sha256}; + +use super::admission::{ + admit_client_evidence, SccmClientAdmittedEvidence, SccmClientCapturedPayload, + SccmClientEvidenceAdmissionError, MAX_SCCM_CLIENT_ADMISSION_AGGREGATE_SEAL_BYTES, + MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES, MAX_SCCM_CLIENT_ADMISSION_SOURCE_SEAL_BYTES, +}; +use super::{assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle}; +use crate::parser::ccm::{observe_bounded_scans, CcmBoundedScanObservation}; +use crate::sccm::{ + SccmArtifact, SccmCoverageState, SccmExtractionGapKind, SccmRole, SccmRotation, + SccmTimeOrderingState, +}; + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[test] +fn aggregate_integrity_hashing_has_one_explicit_total_cap() { + assert_eq!( + MAX_SCCM_CLIENT_ADMISSION_AGGREGATE_SEAL_BYTES, + MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES + MAX_SCCM_CLIENT_ADMISSION_SOURCE_SEAL_BYTES + ); + assert_eq!( + MAX_SCCM_CLIENT_ADMISSION_AGGREGATE_SEAL_BYTES, + 4 * 1024 * 1024 + 2 * 1024 * 1024 + ); +} + +fn bundle() -> SccmClientIntakeBundle { + SccmClientIntakeBundle { + artifacts: vec![artifact("policy-agent", "PolicyAgent.log")], + capture_gaps: Vec::new(), + } +} + +fn bundle_with_bound_policy(bytes: &[u8]) -> SccmClientIntakeBundle { + let mut bundle = bundle(); + bind_artifact_to_bytes(&mut bundle.artifacts[0], bytes); + bundle +} + +fn artifact(identity: &str, basename: &str) -> SccmClientIntakeArtifact { + let artifact_id = format!("fixture-{identity}"); + let bytes = payload_bytes_for(&artifact_id, "+000"); + artifact_bound_to(identity, basename, &bytes) +} + +fn artifact_bound_to(identity: &str, basename: &str, bytes: &[u8]) -> SccmClientIntakeArtifact { + let group = match basename { + "PolicyAgent.log" => "client-policy-agent", + "CIAgent.log" => "client-policy-state", + _ => panic!("test artifact must be catalogued"), + }; + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: format!("fixture-{identity}"), + display_name: basename.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some(format!("synthetic-{identity}")), + rotation_lineage: None, + relative_path: Some(format!("evidence/{group}/current/{basename}")), + fragment_complete: Some(true), + declared_byte_length: Some(bytes.len() as u64), + content_sha256: Some(digest(bytes)), + } +} + +fn payload() -> SccmClientCapturedPayload { + payload_for("fixture-policy-agent", "+000") +} + +fn payload_for(artifact_id: &str, offset: &str) -> SccmClientCapturedPayload { + payload_from_bytes(artifact_id, payload_bytes_for(artifact_id, offset)) +} + +fn payload_bytes_for(artifact_id: &str, offset: &str) -> Vec { + ccm_record(&format!("SYNTHETIC FIXTURE {artifact_id}"), offset).into_bytes() +} + +fn payload_from_bytes(artifact_id: &str, bytes: Vec) -> SccmClientCapturedPayload { + match SccmClientCapturedPayload::new(artifact_id.to_owned(), bytes) { + Ok(payload) => payload, + Err(error) => panic!("test payload must satisfy its public boundary: {error}"), + } +} + +fn ccm_record(message: &str, offset: &str) -> String { + format!( + concat!( + "\n" + ), + message = message, + offset = offset, + ) +} + +fn utf16_with_bom(value: &str, little_endian: bool) -> Vec { + let mut bytes = if little_endian { + vec![0xff, 0xfe] + } else { + vec![0xfe, 0xff] + }; + for unit in value.encode_utf16() { + let encoded = if little_endian { + unit.to_le_bytes() + } else { + unit.to_be_bytes() + }; + bytes.extend_from_slice(&encoded); + } + bytes +} + +fn utf32_with_bom(value: &str, little_endian: bool) -> Vec { + let mut bytes = if little_endian { + vec![0xff, 0xfe, 0x00, 0x00] + } else { + vec![0x00, 0x00, 0xfe, 0xff] + }; + for character in value.chars() { + let encoded = if little_endian { + u32::from(character).to_le_bytes() + } else { + u32::from(character).to_be_bytes() + }; + bytes.extend_from_slice(&encoded); + } + bytes +} + +fn bind_artifact_to_bytes(artifact: &mut SccmClientIntakeArtifact, bytes: &[u8]) { + artifact.declared_byte_length = Some(bytes.len() as u64); + artifact.content_sha256 = Some(digest(bytes)); +} + +fn payload_bytes_padded_to(artifact_id: &str, total_bytes: usize) -> Vec { + let mut bytes = payload_bytes_for(artifact_id, "+000"); + assert!(bytes.len() <= total_bytes, "test payload remains valid"); + bytes.resize(total_bytes, b' '); + bytes +} + +fn repeated_record_bytes(record_count: usize, message: &str) -> Vec { + ccm_record(message, "+000") + .repeat(record_count) + .into_bytes() +} + +fn numbered_artifact(number: u32) -> SccmClientIntakeArtifact { + let artifact_id = format!("sccm-artifact:v1:sha256:{number:064x}"); + let bytes = payload_bytes_for(&artifact_id, "+000"); + numbered_artifact_bound_to(number, &bytes) +} + +fn numbered_artifact_bound_to(number: u32, bytes: &[u8]) -> SccmClientIntakeArtifact { + let artifact_id = format!("sccm-artifact:v1:sha256:{number:064x}"); + let basename = format!("PolicyAgent.log.{number}"); + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id, + display_name: basename.clone(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), + rotation: SccmRotation::Numbered(number), + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some(format!("sha256:{number:064x}")), + rotation_lineage: None, + relative_path: Some(format!( + "evidence/client-policy-agent/numbered-{number}/{basename}" + )), + fragment_complete: Some(true), + declared_byte_length: Some(bytes.len() as u64), + content_sha256: Some(digest(bytes)), + } +} + +fn admission_error( + result: Result, + context: &str, +) -> SccmClientEvidenceAdmissionError { + match result { + Ok(_) => panic!("{context}"), + Err(error) => error, + } +} + +#[test] +fn admission_seals_canonical_records_from_complete_captured_payloads() { + let bundle = bundle(); + let assessment = assess_client_intake(&bundle).expect("fixture assessment is canonical"); + + let admitted = admit_client_evidence(&bundle, &assessment, &[payload()]) + .expect("a complete payload with the registered profile is admitted"); + + assert_eq!(admitted.evidence().expect("valid seal").len(), 1); + assert!(admitted.verify_integrity().is_ok()); + assert_eq!( + admitted + .source_coverage("client-policy-agent") + .expect("valid seal"), + Some(&SccmCoverageState::Captured) + ); + assert!(admitted + .extract_keys_for_artifact("fixture-policy-agent") + .is_ok()); + assert!(admitted + .require_captured_source("client-policy-agent") + .is_ok()); + assert!(admitted + .require_captured_source("client-policy-state") + .is_err()); + assert!(admitted.require_captured_source("not-a-source").is_err()); +} + +#[test] +fn admission_rejects_missing_extra_duplicate_and_swapped_payloads() { + let mut bundle = bundle(); + bundle + .artifacts + .push(artifact("policy-state", "CIAgent.log")); + let assessment = assess_client_intake(&bundle).expect("two payload fixture is canonical"); + assert!(admit_client_evidence(&bundle, &assessment, &[payload()]).is_err()); + + assert!(admit_client_evidence( + &bundle, + &assessment, + &[ + payload(), + payload_for("fixture-policy-state", "+000"), + payload_for("fixture-unknown", "+000"), + ] + ) + .is_err()); + + assert!(admit_client_evidence(&bundle, &assessment, &[payload(), payload()]).is_err()); + + assert!(admit_client_evidence( + &bundle, + &assessment, + &[ + payload(), + payload_from_bytes( + "fixture-policy-state", + payload_bytes_for("fixture-policy-agent", "+000"), + ), + ] + ) + .is_err()); +} + +#[test] +fn admission_distinguishes_within_cap_extra_payloads_from_missing_payloads() { + let bundle = bundle(); + let assessment = assess_client_intake(&bundle).expect("one payload fixture is canonical"); + + let missing = admission_error( + admit_client_evidence(&bundle, &assessment, &[]), + "an omitted eligible payload must fail closed", + ); + assert_eq!(missing, SccmClientEvidenceAdmissionError::MissingPayload); + + let extra = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[payload(), payload_for("fixture-policy-approved", "+000")], + ), + "an additional within-cap payload must fail closed", + ); + assert_eq!(extra, SccmClientEvidenceAdmissionError::ExtraPayload); +} + +#[test] +fn admission_rejects_payload_digest_and_length_mismatches() { + let mut bad_digest_bundle = bundle(); + bad_digest_bundle.artifacts[0].content_sha256 = Some("0".repeat(64)); + let bad_digest_assessment = + assess_client_intake(&bad_digest_bundle).expect("wrong digest remains canonical metadata"); + assert!( + admit_client_evidence(&bad_digest_bundle, &bad_digest_assessment, &[payload()]).is_err() + ); + + let mut bad_length_bundle = bundle(); + bad_length_bundle.artifacts[0].declared_byte_length = bad_length_bundle.artifacts[0] + .declared_byte_length + .and_then(|length| length.checked_add(1)); + let bad_length_assessment = + assess_client_intake(&bad_length_bundle).expect("wrong length remains canonical metadata"); + assert!( + admit_client_evidence(&bad_length_bundle, &bad_length_assessment, &[payload()]).is_err() + ); +} + +#[test] +fn admission_rejects_boms_that_do_not_match_the_declared_encoding() { + let record = ccm_record("declared encoding authority", "+000"); + let mut utf8_bom = vec![0xef, 0xbb, 0xbf]; + utf8_bom.extend_from_slice(record.as_bytes()); + let cases = [ + ("utf-8", utf16_with_bom(&record, true)), + ("utf-16le", utf16_with_bom(&record, false)), + ("utf-16be", utf8_bom.clone()), + ("windows-1252", utf8_bom), + ]; + + for (declared_encoding, bytes) in cases { + let mut bundle = bundle_with_bound_policy(&bytes); + bundle.artifacts[0].artifact.encoding = Some(declared_encoding.to_owned()); + let assessment = assess_client_intake(&bundle) + .expect("a declared encoding and byte binding remain canonical intake metadata"); + let error = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[payload_from_bytes("fixture-policy-agent", bytes)], + ), + "a mismatched Unicode BOM must not override declared encoding authority", + ); + assert_eq!( + error, + SccmClientEvidenceAdmissionError::InvalidEncoding, + "declared {declared_encoding}" + ); + } +} + +#[test] +fn admission_rejects_utf32_boms_before_utf16_prefix_matching() { + let record = ccm_record("unsupported UTF-32 encoding", "+000"); + let cases = [ + ("utf-16le", utf32_with_bom(&record, true)), + ("windows-1252", utf32_with_bom(&record, false)), + ]; + + for (declared_encoding, bytes) in cases { + let mut bundle = bundle_with_bound_policy(&bytes); + bundle.artifacts[0].artifact.encoding = Some(declared_encoding.to_owned()); + let assessment = assess_client_intake(&bundle) + .expect("an unsupported BOM remains canonical intake metadata"); + let error = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[payload_from_bytes("fixture-policy-agent", bytes)], + ), + "UTF-32 BOMs must not inherit another declared encoding", + ); + assert_eq!( + error, + SccmClientEvidenceAdmissionError::InvalidEncoding, + "declared {declared_encoding}" + ); + } +} + +#[test] +fn admission_accepts_only_matching_unicode_boms() { + let record = ccm_record("matching declared encoding", "+000"); + let mut utf8_bom = vec![0xef, 0xbb, 0xbf]; + utf8_bom.extend_from_slice(record.as_bytes()); + let cases = [ + ("utf-8", utf8_bom), + ("utf-16le", utf16_with_bom(&record, true)), + ("utf-16be", utf16_with_bom(&record, false)), + ]; + + for (declared_encoding, bytes) in cases { + let mut bundle = bundle_with_bound_policy(&bytes); + bundle.artifacts[0].artifact.encoding = Some(declared_encoding.to_owned()); + let assessment = assess_client_intake(&bundle) + .expect("a matching BOM remains canonical intake metadata"); + admit_client_evidence( + &bundle, + &assessment, + &[payload_from_bytes("fixture-policy-agent", bytes)], + ) + .unwrap_or_else(|error| { + panic!("matching {declared_encoding} BOM must be admitted: {error}") + }); + } +} + +#[test] +fn admission_accepts_the_exact_cap_and_rejects_payload_overflow_before_reassessment() { + let artifacts = (1..=super::MAX_SCCM_CLIENT_INTAKE_ARTIFACTS as u32) + .map(numbered_artifact) + .collect::>(); + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).expect("exact artifact cap is canonical"); + let payloads = bundle + .artifacts + .iter() + .map(|artifact| payload_for(&artifact.artifact.artifact_id, "+000")) + .collect::>(); + if let Err(error) = admit_client_evidence(&bundle, &assessment, &payloads) { + panic!("the exact payload and record cap must remain admissible: {error}"); + } + + let mut overflow = payloads; + overflow.push(payload_for("fixture-policy-approved", "+000")); + let error = admission_error( + admit_client_evidence(&bundle, &assessment, &overflow), + "the global payload-count guard must reject 4,097 payloads", + ); + assert_eq!( + error, + SccmClientEvidenceAdmissionError::PayloadLimitExceeded + ); +} + +#[test] +fn admission_rejects_unusable_bytes_but_retains_profile_and_time_gaps() { + let mut capped = bundle(); + capped.artifacts[0].artifact.coverage = SccmCoverageState::Capped; + capped.artifacts[0].fragment_complete = Some(false); + capped.artifacts[0].declared_byte_length = None; + capped.artifacts[0].content_sha256 = None; + let capped_assessment = assess_client_intake(&capped).expect("capped state is explicit"); + assert!(admit_client_evidence(&capped, &capped_assessment, &[payload()]).is_err()); + + let mut incomplete = bundle(); + incomplete.artifacts[0].fragment_complete = Some(false); + incomplete.artifacts[0].declared_byte_length = None; + incomplete.artifacts[0].content_sha256 = None; + let incomplete_assessment = + assess_client_intake(&incomplete).expect("incomplete boundary is explicit"); + assert!(admit_client_evidence(&incomplete, &incomplete_assessment, &[payload()]).is_err()); + + let mut unknown_profile = bundle(); + unknown_profile.artifacts[0].artifact.configmgr_version = Some("5.00.9999.1000".to_owned()); + let unknown_profile_assessment = + assess_client_intake(&unknown_profile).expect("unknown version remains canonical coverage"); + let unknown = + admit_client_evidence(&unknown_profile, &unknown_profile_assessment, &[payload()]) + .expect("unknown profile remains sealed evidence"); + let unknown_extraction = unknown + .extract_keys_for_artifact("fixture-policy-agent") + .expect("unknown profile has a sealed extraction gap"); + assert!(unknown_extraction.results()[0] + .gaps + .iter() + .any(|gap| gap.kind == SccmExtractionGapKind::UnvalidatedVersion)); + + let malformed_bytes = b"not a CCM logical record".to_vec(); + let malformed_bundle = bundle_with_bound_policy(&malformed_bytes); + let malformed_assessment = + assess_client_intake(&malformed_bundle).expect("malformed bytes remain bound intake"); + assert!(admit_client_evidence( + &malformed_bundle, + &malformed_assessment, + &[payload_from_bytes("fixture-policy-agent", malformed_bytes)] + ) + .is_err()); + + let invalid_offset_bytes = payload_bytes_for("fixture-policy-agent", "+9999"); + let invalid_offset_bundle = bundle_with_bound_policy(&invalid_offset_bytes); + let invalid_offset_assessment = assess_client_intake(&invalid_offset_bundle) + .expect("invalid record time remains bound intake metadata"); + let invalid_offset = admit_client_evidence( + &invalid_offset_bundle, + &invalid_offset_assessment, + &[payload_from_bytes( + "fixture-policy-agent", + invalid_offset_bytes, + )], + ) + .expect("non-comparable time remains sealed evidence"); + assert_eq!( + invalid_offset.evidence().expect("sealed evidence")[0] + .timestamp + .ordering_state, + SccmTimeOrderingState::OffsetInvalid + ); +} + +#[test] +fn admission_rejects_unclosed_ccm_suffix_even_when_manifest_claims_complete() { + let mut truncated_bytes = payload_bytes_for("fixture-policy-agent", "+000"); + truncated_bytes.extend_from_slice(b" error, + Ok(_) => panic!("the bytes-only constructor must cap per-payload parser work"), + }; + assert_eq!( + error.to_string(), + "client evidence admission payload exceeds the v1 per-payload byte cap" + ); + + let mut aggregate_artifacts = Vec::new(); + let mut aggregate_payloads = Vec::new(); + for number in 1..=5 { + let artifact_id = format!("sccm-artifact:v1:sha256:{number:064x}"); + let bytes = payload_bytes_padded_to(&artifact_id, 4 * 1024 * 1024); + aggregate_artifacts.push(numbered_artifact_bound_to(number, &bytes)); + aggregate_payloads.push(payload_from_bytes(&artifact_id, bytes)); + } + let aggregate_bundle = SccmClientIntakeBundle { + artifacts: aggregate_artifacts, + capture_gaps: Vec::new(), + }; + let aggregate_assessment = + assess_client_intake(&aggregate_bundle).expect("aggregate fixture assessment is canonical"); + let error = admission_error( + admit_client_evidence( + &aggregate_bundle, + &aggregate_assessment, + &aggregate_payloads, + ), + "aggregate parser work must be capped before hashing every payload", + ); + assert_eq!( + error.to_string(), + "client evidence admission aggregate payload bytes exceed the v1 cap" + ); + + let too_many_record_bytes = repeated_record_bytes(4_097, "synthetic record"); + let too_many_record_bundle = bundle_with_bound_policy(&too_many_record_bytes); + let too_many_record_assessment = assess_client_intake(&too_many_record_bundle) + .expect("record-limit fixture intake is canonical"); + let error = admission_error( + admit_client_evidence( + &too_many_record_bundle, + &too_many_record_assessment, + &[payload_from_bytes( + "fixture-policy-agent", + too_many_record_bytes, + )], + ), + "logical-record expansion must be capped before evidence retention", + ); + assert_eq!( + error.to_string(), + "client evidence admission logical record count exceeds the v1 cap" + ); + + let retained_message = "x".repeat(3_000); + let oversized_retained_bytes = repeated_record_bytes(1_024, &retained_message); + let oversized_retained_bundle = bundle_with_bound_policy(&oversized_retained_bytes); + let oversized_retained_assessment = assess_client_intake(&oversized_retained_bundle) + .expect("retained-limit fixture intake is canonical"); + let error = admission_error( + admit_client_evidence( + &oversized_retained_bundle, + &oversized_retained_assessment, + &[payload_from_bytes( + "fixture-policy-agent", + oversized_retained_bytes, + )], + ), + "retained evidence and seal input must remain bounded", + ); + assert_eq!( + error.to_string(), + "client evidence admission retained evidence exceeds the v1 byte cap" + ); +} + +#[test] +fn admission_applies_the_integrity_seal_cap_independently_of_retained_memory() { + let escaped_message = "\0".repeat(700_000); + let bytes = repeated_record_bytes(1, &escaped_message); + let bundle = bundle_with_bound_policy(&bytes); + let assessment = assess_client_intake(&bundle).expect("seal-cap fixture intake is canonical"); + + let error = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[payload_from_bytes("fixture-policy-agent", bytes)], + ), + "JSON escaping must not bypass the independent seal-work cap", + ); + assert_eq!( + error, + SccmClientEvidenceAdmissionError::IntegritySealLimitExceeded + ); +} + +#[test] +fn admission_enforces_logical_record_cap_across_all_payloads() { + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + for (number, record_count) in [(1, 4_095), (2, 4_096)] { + let artifact_id = format!("sccm-artifact:v1:sha256:{number:064x}"); + let bytes = repeated_record_bytes(record_count, "x"); + artifacts.push(numbered_artifact_bound_to(number, &bytes)); + payloads.push(payload_from_bytes(&artifact_id, bytes)); + } + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + let assessment = + assess_client_intake(&bundle).expect("two captured rotations are canonical intake"); + + let (result, observations) = + observe_bounded_scans(|| admit_client_evidence(&bundle, &assessment, &payloads)); + let error = admission_error( + result, + "the logical-record cap must apply across the complete bundle", + ); + assert_eq!( + error.to_string(), + "client evidence admission logical record count exceeds the v1 cap" + ); + assert_eq!( + observations, + vec![ + CcmBoundedScanObservation { + record_limit: 4_096, + retained_records: 4_095, + record_limit_exceeded: false, + }, + CcmBoundedScanObservation { + record_limit: 1, + retained_records: 1, + record_limit_exceeded: true, + }, + ], + "the second scanner must receive and retain only the aggregate remainder" + ); + assert_eq!( + observations + .iter() + .map(|observation| observation.retained_records) + .sum::(), + 4_096, + "admission must never materialize more than the bundle record cap" + ); +} + +#[test] +fn admission_reassesses_bundle_and_is_deterministic_across_payload_order() { + let mut bundle = bundle(); + bundle + .artifacts + .push(artifact("policy-state", "CIAgent.log")); + let canonical = assess_client_intake(&bundle).expect("canonical assessment"); + let mut forged = canonical.clone(); + forged + .groups + .iter_mut() + .find(|group| group.logical_artifact_id == "client-policy-agent") + .expect("fixture contains policy-agent group") + .fragments + .clear(); + assert!(admit_client_evidence( + &bundle, + &forged, + &[payload(), payload_for("fixture-policy-state", "+000",)] + ) + .is_err()); + + let forward = admit_client_evidence( + &bundle, + &canonical, + &[payload(), payload_for("fixture-policy-state", "+000")], + ) + .expect("forward payload ordering is admitted"); + let reverse = admit_client_evidence( + &bundle, + &canonical, + &[payload_for("fixture-policy-state", "+000"), payload()], + ) + .expect("reverse payload ordering is admitted"); + assert_eq!( + forward.evidence().expect("forward seal"), + reverse.evidence().expect("reverse seal") + ); + assert_eq!(forward.integrity_seal(), reverse.integrity_seal()); +} + +#[test] +fn admission_integrity_rejects_test_only_record_profile_and_identity_collisions() { + let bundle = bundle(); + let assessment = assess_client_intake(&bundle).expect("fixture assessment is canonical"); + + let mut record_mutation = + admit_client_evidence(&bundle, &assessment, &[payload()]).expect("admitted evidence"); + record_mutation.test_only_mutate_first_message(); + assert!(record_mutation.verify_integrity().is_err()); + assert!(record_mutation.evidence().is_err()); + + let mut profile_mutation = + admit_client_evidence(&bundle, &assessment, &[payload()]).expect("admitted evidence"); + profile_mutation.test_only_mutate_first_profile(); + assert!(profile_mutation.verify_integrity().is_err()); + assert!(profile_mutation + .extract_keys_for_artifact("fixture-policy-agent") + .is_err()); + + let mut source_mutation = + admit_client_evidence(&bundle, &assessment, &[payload()]).expect("admitted evidence"); + source_mutation.test_only_mutate_first_source_authority(); + assert!(source_mutation.verify_integrity().is_err()); + + let mut collision = + admit_client_evidence(&bundle, &assessment, &[payload()]).expect("admitted evidence"); + collision.test_only_duplicate_first_evidence(); + assert!(collision.verify_integrity().is_err()); +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs new file mode 100644 index 000000000..eaf9ad355 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -0,0 +1,949 @@ +use sha2::{Digest, Sha256}; + +use super::admission::{ + admit_client_evidence, SccmClientAdmittedEvidence, SccmClientAdmittedKeyExtraction, + SccmClientCapturedPayload, SccmClientEvidenceAdmissionError, +}; +use super::{ + assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle, + SccmClientIntakeCaptureGap, +}; +use crate::sccm::{ + extract_keys, normalize_key, SccmArtifact, SccmArtifactFamily, SccmCorrelationKeyKind, + SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmExtractionGapKind, SccmExtractionProfile, + SccmExtractionProfileMaturity, SccmKeyConfidence, SccmRole, SccmRotation, + SccmTimeOrderingState, SccmTimestamp, SCCM_ADMIN_SERVICE_SYNTHETIC_KEY_PROFILE_ID, + SCCM_EXPERIMENTAL_KEY_PROFILE_ID, SCCM_POLICY_KEY_PROFILE_ID, + SCCM_PROVIDER_SYNTHETIC_KEY_PROFILE_ID, +}; + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn ccm_bytes(message: &str) -> Vec { + format!( + concat!( + "\n" + ), + message = message, + ) + .into_bytes() +} + +fn source_group(basename: &str) -> &'static str { + match basename { + "PolicyAgent.log" => "client-policy-agent", + "CAS.log" => "client-content", + "AppIntentEval.log" => "client-app-intent", + "AppEnforce.log" => "client-app-enforce", + "ccmsetup.log" | "ccmsetup.lo_" => "client-ccmsetup", + "client.msi.log" => "client-ccmsetup", + "ReportingEvents.log" => "client-windows-update-supplemental", + "CustomVendorHook.log" => "unknown", + _ => panic!("authority fixture basename must be declared here"), + } +} + +fn artifact( + identity: &str, + basename: &str, + coverage: SccmCoverageState, + fragment_complete: bool, + binding: Option<&[u8]>, +) -> SccmClientIntakeArtifact { + let physical = matches!( + coverage, + SccmCoverageState::Captured | SccmCoverageState::Capped | SccmCoverageState::ParseFailed + ); + let (declared_byte_length, content_sha256) = binding + .map(|bytes| (Some(bytes.len() as u64), Some(digest(bytes)))) + .unwrap_or((None, None)); + let group = source_group(basename); + let relative_path = (physical && group != "unknown") + .then(|| format!("evidence/{group}/current/{basename}")) + .or_else(|| { + (physical && group == "unknown").then(|| format!("evidence/{group}/{basename}")) + }); + + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: format!("fixture-{identity}"), + display_name: basename.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: physical.then(|| format!("synthetic-{identity}")), + rotation_lineage: None, + relative_path, + fragment_complete: Some(fragment_complete), + declared_byte_length, + content_sha256, + } +} + +fn bundle_with(artifacts: Vec) -> SccmClientIntakeBundle { + SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + } +} + +fn payload(artifact_id: &str, bytes: Vec) -> SccmClientCapturedPayload { + match SccmClientCapturedPayload::new(artifact_id.to_owned(), bytes) { + Ok(payload) => payload, + Err(error) => panic!("authority fixture payload must be valid: {error}"), + } +} + +fn admission_error( + result: Result, + context: &str, +) -> SccmClientEvidenceAdmissionError { + match result { + Ok(_) => panic!("{context}"), + Err(error) => error, + } +} + +#[test] +fn admission_rejects_substituted_valid_ccm_bytes_against_the_intake_binding() { + let bound = ccm_bytes("bound policy evidence"); + let substituted = ccm_bytes("different but valid policy evidence"); + let bundle = bundle_with(vec![artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&bound), + )]); + let assessment = assess_client_intake(&bundle).expect("bound intake is canonical"); + + let error = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-policy", substituted)], + ), + "valid substituted bytes must not inherit the intake artifact's authority", + ); + assert_eq!( + error, + SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch + ); +} + +#[test] +fn admission_rejects_a_mutated_assessment_content_binding() { + let bytes = ccm_bytes("bound policy evidence"); + let bundle = bundle_with(vec![artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&bytes), + )]); + let mut assessment = assess_client_intake(&bundle).expect("bound intake is canonical"); + assessment.physical_artifacts[0].content_sha256 = Some("0".repeat(64)); + + let error = admission_error( + admit_client_evidence(&bundle, &assessment, &[payload("fixture-policy", bytes)]), + "a caller-mutated assessment binding must not become authority", + ); + assert_eq!(error, SccmClientEvidenceAdmissionError::AssessmentMutation); +} + +#[test] +fn intake_content_binding_is_paired_lowercase_and_capture_local() { + let bytes = ccm_bytes("bound policy evidence"); + let valid = artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&bytes), + ); + let assessment = assess_client_intake(&bundle_with(vec![valid.clone()])) + .expect("a recognized complete capture may carry a content binding"); + let projected = &assessment.physical_artifacts[0]; + assert_eq!(projected.declared_byte_length, Some(bytes.len() as u64)); + assert_eq!( + projected.content_sha256.as_deref(), + Some(digest(&bytes).as_str()) + ); + + let mut missing_digest = valid.clone(); + missing_digest.content_sha256 = None; + assert_eq!( + assess_client_intake(&bundle_with(vec![missing_digest])), + Err(super::SccmClientIntakeError::InvalidContentBinding) + ); + + let mut missing_length = valid.clone(); + missing_length.declared_byte_length = None; + assert_eq!( + assess_client_intake(&bundle_with(vec![missing_length])), + Err(super::SccmClientIntakeError::InvalidContentBinding) + ); + + let mut uppercase_digest = valid.clone(); + uppercase_digest.content_sha256 = uppercase_digest + .content_sha256 + .map(|value| value.to_uppercase()); + assert_eq!( + assess_client_intake(&bundle_with(vec![uppercase_digest])), + Err(super::SccmClientIntakeError::InvalidContentBinding) + ); + + for mut inadmissible in [ + artifact( + "denied", + "PolicyAgent.log", + SccmCoverageState::AccessDenied, + false, + Some(&bytes), + ), + artifact( + "capped", + "CAS.log", + SccmCoverageState::Capped, + false, + Some(&bytes), + ), + artifact( + "incomplete", + "AppIntentEval.log", + SccmCoverageState::Captured, + false, + Some(&bytes), + ), + artifact( + "custom", + "CustomVendorHook.log", + SccmCoverageState::Captured, + true, + Some(&bytes), + ), + ] { + assert_eq!( + assess_client_intake(&bundle_with(vec![inadmissible.clone()])), + Err(super::SccmClientIntakeError::InvalidContentBinding), + "content authority must be absent outside a recognized complete capture" + ); + inadmissible.declared_byte_length = None; + inadmissible.content_sha256 = None; + assess_client_intake(&bundle_with(vec![inadmissible])) + .expect("the same coverage remains representable without content authority"); + } +} + +#[test] +fn legacy_intake_remains_assessable_but_cannot_admit_bytes() { + let bytes = ccm_bytes("legacy policy evidence"); + let legacy = artifact( + "policy-approved", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + None, + ); + let bundle = bundle_with(vec![legacy]); + let assessment = assess_client_intake(&bundle).expect("legacy intake remains assessment-only"); + let wire = serde_json::to_value(&bundle).expect("legacy intake remains serializable"); + assert!(wire["artifacts"][0].get("declaredByteLength").is_none()); + assert!(wire["artifacts"][0].get("contentSha256").is_none()); + + let error = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-policy-approved", bytes)], + ), + "legacy intake must not authorize caller-supplied bytes", + ); + assert_eq!( + error, + SccmClientEvidenceAdmissionError::MissingContentBinding + ); +} + +#[test] +fn admission_rejects_swapped_duplicate_extra_and_missing_payloads() { + let policy_bytes = ccm_bytes("policy evidence"); + let content_bytes = ccm_bytes("content evidence"); + let bundle = bundle_with(vec![ + artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&policy_bytes), + ), + artifact( + "content", + "CAS.log", + SccmCoverageState::Captured, + true, + Some(&content_bytes), + ), + ]); + let assessment = assess_client_intake(&bundle).expect("two bound sources are canonical"); + + let swapped = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[ + payload("fixture-policy", content_bytes.clone()), + payload("fixture-content", policy_bytes.clone()), + ], + ), + "swapped valid payloads must not be admitted", + ); + assert_eq!( + swapped, + SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch + ); + + let duplicate = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[ + payload("fixture-policy", policy_bytes.clone()), + payload("fixture-policy", policy_bytes.clone()), + ], + ), + "duplicate payload identities must fail closed", + ); + assert_eq!( + duplicate, + SccmClientEvidenceAdmissionError::DuplicatePayload + ); + + let extra = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[ + payload("fixture-policy", policy_bytes.clone()), + payload("fixture-policy-two", ccm_bytes("extra evidence")), + ], + ), + "an extra syntactically valid payload identity must fail closed", + ); + assert_eq!(extra, SccmClientEvidenceAdmissionError::ExtraPayload); + + let missing = admission_error( + admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-policy", policy_bytes)], + ), + "a missing bound payload must fail closed", + ); + assert_eq!(missing, SccmClientEvidenceAdmissionError::MissingPayload); +} + +#[test] +fn incomplete_and_capped_sources_fail_locally_without_blocking_bound_policy() { + let policy_bytes = ccm_bytes("policy evidence"); + let bundle = bundle_with(vec![ + artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&policy_bytes), + ), + artifact( + "content-capped", + "CAS.log", + SccmCoverageState::Capped, + false, + None, + ), + artifact( + "intent-incomplete", + "AppIntentEval.log", + SccmCoverageState::Captured, + false, + None, + ), + artifact( + "enforce-missing", + "AppEnforce.log", + SccmCoverageState::Captured, + true, + None, + ), + ]); + let assessment = assess_client_intake(&bundle).expect("mixed source coverage is canonical"); + let admitted = match admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-policy", policy_bytes)], + ) { + Ok(admitted) => admitted, + Err(error) => panic!("unrelated source gaps must not block policy admission: {error}"), + }; + + assert!(admitted + .require_captured_source("client-policy-agent") + .is_ok()); + assert!(admitted.require_captured_source("client-content").is_err()); + assert!(admitted + .require_captured_source("client-app-intent") + .is_err()); + assert!(admitted + .require_captured_source("client-app-enforce") + .is_err()); + assert_eq!(admitted.evidence().expect("valid authority seal").len(), 1); +} + +#[test] +fn missing_fragment_encoding_is_a_local_admission_gap() { + let policy_bytes = ccm_bytes("policy evidence"); + let content_bytes = ccm_bytes("content evidence without decoding provenance"); + let mut content = artifact( + "content-missing", + "CAS.log", + SccmCoverageState::Captured, + true, + Some(&content_bytes), + ); + content.artifact.encoding = None; + let bundle = bundle_with(vec![ + artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&policy_bytes), + ), + content, + ]); + let assessment = assess_client_intake(&bundle) + .expect("missing decoding provenance remains an assessable local coverage gap"); + + let admitted = admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-policy", policy_bytes)], + ) + .expect("a fragment without decoding provenance must not block unrelated bound evidence"); + + assert!(admitted + .require_captured_source("client-policy-agent") + .is_ok()); + assert_eq!( + admitted.require_captured_source("client-content"), + Err(SccmClientEvidenceAdmissionError::SourceCoverageUnavailable), + "the un-decodable source remains a local admission gap" + ); + assert_eq!(admitted.evidence().expect("valid authority seal").len(), 1); +} + +#[test] +fn admitted_profile_is_bound_to_the_catalogued_source_family() { + let bytes = ccm_bytes("policy evidence"); + let bundle = bundle_with(vec![artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&bytes), + )]); + let assessment = assess_client_intake(&bundle).expect("bound policy intake is canonical"); + let admitted = + match admit_client_evidence(&bundle, &assessment, &[payload("fixture-policy", bytes)]) { + Ok(admitted) => admitted, + Err(error) => panic!("bound policy evidence must be admitted: {error}"), + }; + + let extraction = admitted + .extract_keys_for_artifact("fixture-policy") + .expect("admitted artifact has sealed key-extraction authority"); + assert_eq!( + extraction.artifact_family(), + &SccmArtifactFamily::ClientPolicy + ); + assert_eq!(extraction.artifact_id(), "fixture-policy"); +} + +#[test] +fn recognized_non_ccm_sources_cannot_enter_raw_ccm_admission() { + for (identity, basename) in [ + ("client-setup", "client.msi.log"), + ("reporting-supplemental", "ReportingEvents.log"), + ] { + let bytes = ccm_bytes("CCM-shaped bytes from a non-CCM source"); + let bundle = bundle_with(vec![artifact( + identity, + basename, + SccmCoverageState::Captured, + true, + Some(&bytes), + )]); + let assessment = assess_client_intake(&bundle).expect("bound intake is canonical"); + + assert!( + admit_client_evidence( + &bundle, + &assessment, + &[payload(&format!("fixture-{identity}"), bytes)], + ) + .is_err(), + "{basename} must never authorize raw CCM evidence" + ); + } +} + +#[test] +fn captured_non_ccm_supplement_does_not_block_or_join_policy_admission() { + let policy_bytes = ccm_bytes("policy evidence"); + let supplemental_bytes = ccm_bytes("CCM-shaped supplemental text"); + let bundle = bundle_with(vec![ + artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&policy_bytes), + ), + artifact( + "reporting-supplemental", + "ReportingEvents.log", + SccmCoverageState::Captured, + true, + Some(&supplemental_bytes), + ), + ]); + let assessment = assess_client_intake(&bundle).expect("mixed intake is canonical"); + let admitted = admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-policy", policy_bytes)], + ) + .expect("non-CCM supplemental bytes must not be required for policy admission"); + + assert!(admitted + .require_captured_source("client-policy-agent") + .is_ok()); + assert!(admitted + .require_captured_source("client-windows-update-supplemental") + .is_err()); + assert_eq!(admitted.evidence().expect("valid authority seal").len(), 1); + assert!(admitted + .extract_keys_for_artifact("fixture-reporting-supplemental") + .is_err()); +} + +#[test] +fn non_ccm_sibling_coverage_does_not_block_bound_ccmsetup_admission() { + for (coverage, identity) in [ + (SccmCoverageState::Capped, "client-setup-capped"), + (SccmCoverageState::AccessDenied, "client-setup-denied"), + ] { + let setup_bytes = ccm_bytes("bound ccmsetup evidence"); + let bundle = bundle_with(vec![ + artifact( + "ccmsetup", + "ccmsetup.log", + SccmCoverageState::Captured, + true, + Some(&setup_bytes), + ), + artifact(identity, "client.msi.log", coverage.clone(), false, None), + ]); + let assessment = assess_client_intake(&bundle).expect("mixed setup intake is canonical"); + let admitted = admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-ccmsetup", setup_bytes)], + ) + .expect("a non-CCM sibling must not block exact bound ccmsetup evidence"); + + assert!(admitted.require_captured_source("client-ccmsetup").is_ok()); + assert_eq!( + admitted + .source_coverage("client-ccmsetup") + .expect("valid authority seal"), + Some(&coverage), + "canonical non-CCM coverage remains visible for evidence-first reporting" + ); + assert_eq!(admitted.evidence().expect("valid authority seal").len(), 1); + } +} + +#[test] +fn raw_ccm_sibling_gap_still_blocks_ccmsetup_group_readiness() { + let setup_bytes = ccm_bytes("bound ccmsetup evidence"); + let mut denied_rollback = artifact( + "ccmsetup-denied", + "ccmsetup.lo_", + SccmCoverageState::AccessDenied, + false, + None, + ); + denied_rollback.artifact.rotation = SccmRotation::LoUnderscore; + let bundle = bundle_with(vec![ + artifact( + "ccmsetup", + "ccmsetup.log", + SccmCoverageState::Captured, + true, + Some(&setup_bytes), + ), + denied_rollback, + ]); + let assessment = assess_client_intake(&bundle).expect("raw CCM gap intake is canonical"); + let admitted = admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-ccmsetup", setup_bytes)], + ) + .expect("a raw CCM gap is local readiness state, not global admission failure"); + + assert!(admitted.require_captured_source("client-ccmsetup").is_err()); + assert_eq!(admitted.evidence().expect("valid authority seal").len(), 1); +} + +#[test] +fn raw_ccm_capture_gap_still_blocks_ccmsetup_group_readiness() { + let setup_bytes = ccm_bytes("bound ccmsetup evidence"); + let bundle = SccmClientIntakeBundle { + artifacts: vec![artifact( + "ccmsetup", + "ccmsetup.log", + SccmCoverageState::Captured, + true, + Some(&setup_bytes), + )], + capture_gaps: vec![SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "ccmsetup.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }], + }; + let assessment = assess_client_intake(&bundle).expect("raw CCM capture gap is canonical"); + let admitted = admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-ccmsetup", setup_bytes)], + ) + .expect("a raw CCM capture gap is local readiness state, not admission failure"); + + assert!(admitted.require_captured_source("client-ccmsetup").is_err()); + assert_eq!(admitted.evidence().expect("valid authority seal").len(), 1); +} + +#[test] +fn admitted_policy_extraction_is_sealed_to_the_exact_artifact_and_family() { + let assignment_id = "12345678-1234-1234-1234-123456789abc"; + let policy_bytes = ccm_bytes(&format!("Assignment ID = {assignment_id}")); + let content_bytes = ccm_bytes("Package ID = LAB00001"); + let bundle = bundle_with(vec![ + artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&policy_bytes), + ), + artifact( + "content", + "CAS.log", + SccmCoverageState::Captured, + true, + Some(&content_bytes), + ), + ]); + let assessment = assess_client_intake(&bundle).expect("bound policy intake is canonical"); + let admitted = admit_client_evidence( + &bundle, + &assessment, + &[ + payload("fixture-content", content_bytes), + payload("fixture-policy", policy_bytes), + ], + ) + .expect("bound policy and content evidence must be admitted"); + let extraction: SccmClientAdmittedKeyExtraction = admitted + .extract_keys_for_artifact("fixture-policy") + .expect("policy evidence has sealed extraction authority"); + let result = &extraction.results()[0]; + + assert_eq!(extraction.artifact_id(), "fixture-policy"); + assert_eq!( + extraction.artifact_family(), + &SccmArtifactFamily::ClientPolicy + ); + assert_eq!(extraction.results().len(), 1); + assert_eq!(result.keys.len(), 1); + assert_eq!(result.keys[0].kind, SccmCorrelationKeyKind::AssignmentId); + assert_eq!(result.keys[0].normalized, assignment_id); + assert_eq!(result.keys[0].confidence, SccmKeyConfidence::Low); + assert!(result + .gaps + .iter() + .any(|gap| gap.kind == SccmExtractionGapKind::ExperimentalProfile)); + assert!(result + .gaps + .iter() + .all(|gap| gap.kind != SccmExtractionGapKind::UnvalidatedProfile)); +} + +#[test] +fn caller_constructed_policy_profile_cannot_cross_the_admitted_boundary() { + let assignment_id = "12345678-1234-1234-1234-123456789abc"; + let bytes = ccm_bytes(&format!("Assignment ID = {assignment_id}")); + let bundle = bundle_with(vec![artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&bytes), + )]); + let assessment = assess_client_intake(&bundle).expect("bound policy intake is canonical"); + let admitted = admit_client_evidence(&bundle, &assessment, &[payload("fixture-policy", bytes)]) + .expect("bound policy evidence must be admitted"); + let evidence = &admitted.evidence().expect("valid authority seal")[0]; + let caller_constructed = SccmExtractionProfile { + profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + configmgr_version_prefixes: vec!["5.00.9128.".to_owned()], + validated_artifact_families: vec![SccmArtifactFamily::ClientPolicy], + selected_configmgr_version: Some("5.00.9128.1000".to_owned()), + maturity: SccmExtractionProfileMaturity::Experimental, + }; + + let generic_result = extract_keys(evidence, &caller_constructed); + assert!(generic_result.keys.is_empty()); + assert!(generic_result + .gaps + .iter() + .any(|gap| gap.kind == SccmExtractionGapKind::UnvalidatedProfile)); + + let admitted_result: SccmClientAdmittedKeyExtraction = admitted + .extract_keys_for_artifact("fixture-policy") + .expect("only the admitted authority selects the sealed profile"); + let result = &admitted_result.results()[0]; + + assert_eq!(admitted_result.artifact_id(), "fixture-policy"); + assert_eq!( + admitted_result.artifact_family(), + &SccmArtifactFamily::ClientPolicy + ); + assert_eq!(admitted_result.results().len(), 1); + assert_eq!(result.keys.len(), 1); + assert_eq!(result.keys[0].kind, SccmCorrelationKeyKind::AssignmentId); + assert_eq!(result.keys[0].normalized, assignment_id); + assert_eq!(result.keys[0].confidence, SccmKeyConfidence::Low); + assert!(result + .gaps + .iter() + .any(|gap| gap.kind == SccmExtractionGapKind::ExperimentalProfile)); + assert!(result + .gaps + .iter() + .all(|gap| gap.kind != SccmExtractionGapKind::UnvalidatedProfile)); +} + +#[test] +fn caller_constructed_stable_policy_profile_does_not_mint_exact_keys() { + let bytes = ccm_bytes(concat!( + "Request succeeded ", + "AssignmentId={12345678-1234-1234-1234-123456789abc} ", + "PolicyId={abcdefab-cdef-cdef-cdef-abcdefabcdef}" + )); + let mut policy = artifact( + "policy", + "PolicyAgent.log", + SccmCoverageState::Captured, + true, + Some(&bytes), + ); + policy.artifact.configmgr_version = Some("5.00.TEST.0000".to_owned()); + let bundle = bundle_with(vec![policy]); + let assessment = assess_client_intake(&bundle).expect("stable policy intake is canonical"); + let admitted = admit_client_evidence(&bundle, &assessment, &[payload("fixture-policy", bytes)]) + .expect("stable policy bytes are sealed"); + let evidence = &admitted.evidence().expect("valid seal")[0]; + let caller_constructed = SccmExtractionProfile { + profile_id: SCCM_POLICY_KEY_PROFILE_ID.to_owned(), + configmgr_version_prefixes: vec!["5.00.TEST.0000".to_owned()], + validated_artifact_families: vec![SccmArtifactFamily::ClientPolicy], + selected_configmgr_version: Some("5.00.TEST.0000".to_owned()), + maturity: SccmExtractionProfileMaturity::Stable, + }; + + let generic = extract_keys(evidence, &caller_constructed); + assert!(generic.keys.is_empty()); + assert!(generic + .gaps + .iter() + .all(|gap| gap.kind == SccmExtractionGapKind::UnvalidatedProfile)); + + let sealed = admitted + .extract_keys_for_artifact("fixture-policy") + .expect("admission owns stable profile authority"); + assert_eq!(sealed.results()[0].keys.len(), 2); + assert!(sealed.results()[0] + .keys + .iter() + .all(|key| key.confidence == SccmKeyConfidence::Exact)); +} + +#[test] +fn synthetic_server_profiles_are_exact_registered_tuples_but_keys_remain_low() { + let request_id = "11111111-1111-1111-1111-111111111111"; + let evidence = SccmEvidence { + evidence_id: "synthetic-server-entry".to_owned(), + reference: SccmEvidenceRef { + artifact_id: "synthetic-server-artifact".to_owned(), + entry_id: "synthetic-server-entry".to_owned(), + line_start: Some(1), + line_end: Some(1), + }, + role: SccmRole::Provider, + component: Some("Synthetic".to_owned()), + ccm_source_file: Some("synthetic.cc".to_owned()), + message: format!("RequestId={request_id}"), + timestamp: SccmTimestamp { + original_display: Some("synthetic".to_owned()), + offset_minutes: Some(0), + utc_millis: Some(1), + ordering_state: SccmTimeOrderingState::NormalizedUtc, + }, + execution_context: None, + }; + + for (family, profile_id) in [ + ( + SccmArtifactFamily::Provider, + SCCM_PROVIDER_SYNTHETIC_KEY_PROFILE_ID, + ), + ( + SccmArtifactFamily::AdminService, + SCCM_ADMIN_SERVICE_SYNTHETIC_KEY_PROFILE_ID, + ), + ] { + let profile = SccmExtractionProfile::for_artifact_family(Some("5.00.TEST"), &family); + assert_eq!(profile.profile_id, profile_id); + assert_eq!(profile.configmgr_version_prefixes, ["5.00.TEST"]); + assert_eq!(profile.validated_artifact_families, [family]); + assert_eq!( + profile.selected_configmgr_version.as_deref(), + Some("5.00.TEST") + ); + let extraction = extract_keys(&evidence, &profile); + assert_eq!(extraction.keys.len(), 1); + assert_eq!(extraction.keys[0].confidence, SccmKeyConfidence::Low); + assert_eq!( + extraction.keys[0].extraction_profile_id.as_deref(), + Some(profile_id) + ); + assert!(extraction + .gaps + .iter() + .any(|gap| gap.kind == SccmExtractionGapKind::ExperimentalProfile)); + } + assert_eq!( + normalize_key(SccmCorrelationKeyKind::RequestId, request_id).confidence, + SccmKeyConfidence::Exact + ); +} + +#[test] +fn forged_synthetic_server_profile_cannot_activate_shared_extraction() { + let evidence = SccmEvidence { + evidence_id: "synthetic-forged-entry".to_owned(), + reference: SccmEvidenceRef { + artifact_id: "synthetic-forged-artifact".to_owned(), + entry_id: "synthetic-forged-entry".to_owned(), + line_start: Some(1), + line_end: Some(1), + }, + role: SccmRole::Provider, + component: None, + ccm_source_file: None, + message: "RequestId=11111111-1111-1111-1111-111111111111".to_owned(), + timestamp: SccmTimestamp { + original_display: None, + offset_minutes: Some(0), + utc_millis: Some(1), + ordering_state: SccmTimeOrderingState::NormalizedUtc, + }, + execution_context: None, + }; + let mut forged = SccmExtractionProfile::for_artifact_family( + Some("5.00.TEST"), + &SccmArtifactFamily::Provider, + ); + forged.profile_id.push_str("-forged"); + let extraction = extract_keys(&evidence, &forged); + assert!(extraction.keys.is_empty()); + assert!(extraction + .gaps + .iter() + .any(|gap| gap.kind == SccmExtractionGapKind::UnvalidatedProfile)); +} + +#[test] +fn unregistered_ccm_family_is_admitted_with_an_unvalidated_profile_gap() { + let bytes = ccm_bytes("Package ID = LAB00001"); + let bundle = bundle_with(vec![artifact( + "content", + "CAS.log", + SccmCoverageState::Captured, + true, + Some(&bytes), + )]); + let assessment = assess_client_intake(&bundle).expect("bound content intake is canonical"); + let admitted = + admit_client_evidence(&bundle, &assessment, &[payload("fixture-content", bytes)]) + .expect("raw CCM evidence does not require a validated key-extraction family"); + let extraction = admitted + .extract_keys_for_artifact("fixture-content") + .expect("content evidence has sealed family-bound extraction authority"); + let result = &extraction.results()[0]; + + assert_eq!( + extraction.artifact_family(), + &SccmArtifactFamily::ClientContent + ); + assert!(result.keys.is_empty()); + assert_eq!(result.gaps.len(), 1); + assert_eq!( + result.gaps[0].kind, + SccmExtractionGapKind::UnvalidatedProfile + ); + assert_eq!( + result.gaps[0].candidate_kind, + Some(SccmCorrelationKeyKind::PackageId) + ); +} + +#[test] +fn captured_payload_constructor_rejects_noncanonical_identity() { + let result = SccmClientCapturedPayload::new("C:\\Users\\raw\\PolicyAgent.log", ccm_bytes("x")); + match result { + Err(SccmClientEvidenceAdmissionError::InvalidPayloadArtifactId) => {} + Err(error) => panic!("unexpected payload constructor error: {error}"), + Ok(_) => panic!("raw path identity must not enter the payload boundary"), + } +} 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..e21b4daaa --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -0,0 +1,2365 @@ +//! Issue #322: application, package, and content deployment transactions. +//! +//! The reducer is pure. It turns sealed client evidence 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, SccmRole, SccmTerminalEvidence, SccmTimeOrderingState, + SCCM_EXPERIMENTAL_KEY_PROFILE_ID, +}; + +use super::{SccmClientAdmittedEvidence, SccmClientEvidenceAdmissionError}; + +pub const SCCM_DEPLOYMENT_ANALYSIS_SCHEMA_VERSION: u32 = 1; +pub const SCCM_DEPLOYMENT_PROFILE_ID: &str = SCCM_EXPERIMENTAL_KEY_PROFILE_ID; +pub const SCCM_DEPLOYMENT_VERSION_PREFIX: &str = "5.00.9128."; + +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 REASON_TOPOLOGY_AMBIGUOUS: &str = + "capture one exact content topology for this assignment and CI; conflicting content identities cannot support an outcome"; + +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 capture_complete: bool, + 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 intake-bound client evidence into deployment transactions. +pub fn analyze_client_deployment( + admitted: &SccmClientAdmittedEvidence, +) -> Result { + admitted.verify_integrity()?; + let source_artifacts = admitted.source_artifacts()?; + let incomplete_artifact_ids = source_artifacts + .iter() + .filter_map(|(artifact_id, source)| { + (source.coverage == SccmCoverageState::Captured + && source.fragment_complete != Some(true)) + .then_some(artifact_id.as_str()) + }) + .collect::>(); + let artifacts = source_artifacts + .iter() + .map(|(artifact_id, source)| SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: source.basename.clone(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: None, + collected_at_utc: None, + rotation: source.rotation.clone(), + coverage: source.coverage.clone(), + encoding: None, + }) + .collect::>(); + let coverage = coverage_rows(&artifacts, &incomplete_artifact_ids); + let artifacts_by_id = artifacts + .iter() + .map(|artifact| (artifact.artifact_id.as_str(), artifact)) + .collect::>(); + + let evidence = admitted.evidence()?; + let mut facts = evidence + .iter() + .flat_map(|evidence| { + if incomplete_artifact_ids.contains(evidence.reference.artifact_id.as_str()) { + return Vec::new(); + } + 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 fact_artifact_ids = facts + .iter() + .map(|fact| fact.reference.artifact_id.as_str()) + .collect::>(); + let evidence_artifact_ids = evidence + .iter() + .map(|item| item.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 = evidence_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( + evidence, + &artifacts_by_id, + &fact_artifact_ids, + &incomplete_artifact_ids, + ); + let findings = build_findings(&seeds, &artifacts_by_id); + + Ok(finalize( + selection_state(evidence), + coverage, + transactions, + observations, + findings, + validated_artifact_families, + )) +} + +fn selection_state(evidence: &[SccmEvidence]) -> SccmDeploymentProfileSelectionState { + if !evidence.is_empty() { + 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_PROFILE_ID.to_owned(), + source_version_prefix: SCCM_DEPLOYMENT_VERSION_PREFIX.to_owned(), + content_version_required: true, + key_kinds: key_kinds.into_iter().map(str::to_owned).collect(), + validated_artifact_families, + } +} + +// --------------------------------------------------------------------------- +// Coverage +// --------------------------------------------------------------------------- + +fn coverage_rows( + artifacts: &[SccmArtifact], + incomplete_artifact_ids: &BTreeSet<&str>, +) -> Vec { + let mut grouped = BTreeMap::>::new(); + for artifact in artifacts { + 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 + || incomplete_artifact_ids.contains(artifact.artifact_id.as_str()) + }) + .map(|artifact| artifact.artifact_id.clone()) + .collect::>(); + artifact_ids.sort(); + artifact_ids.dedup(); + let state = combine_coverage(&states); + let capture_complete = state == SccmCoverageState::Captured + && artifacts.iter().all(|artifact| { + !incomplete_artifact_ids.contains(artifact.artifact_id.as_str()) + }); + + SccmDeploymentCoverage { + logical_artifact_id, + state, + capture_complete, + 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] { + 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::Absent => 1, + SccmCoverageState::AccessDenied => 2, + SccmCoverageState::Capped => 3, + SccmCoverageState::Skipped => 4, + SccmCoverageState::Unsupported => 5, + SccmCoverageState::ParseFailed => 6, + } +} + +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() +} + +/// Admission, role, coverage, rotation, and catalog identity must all agree +/// before a record may become a fact. Logical framing and version-profile +/// selection were already sealed by `SccmClientAdmittedEvidence`. +fn admitted_source(evidence: &SccmEvidence, artifact: &SccmArtifact) -> Option { + if artifact.role != SccmRole::Client + || evidence.role != SccmRole::Client + || artifact.coverage != SccmCoverageState::Captured + || !valid_reference(&evidence.reference) + { + 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, + 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(), + 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 has_ambiguous_content_topology(facts: &[&DeploymentFact]) -> bool { + fn multiple(values: impl Iterator) -> bool { + values.collect::>().len() > 1 + } + + multiple(facts.iter().filter_map(|fact| fact.package_id.clone())) + || multiple(facts.iter().filter_map(|fact| fact.content_id.clone())) + || multiple(facts.iter().filter_map(|fact| fact.content_version)) + || multiple( + facts + .iter() + .filter_map(|fact| fact.distribution_point_host_handle.clone()), + ) + || multiple(facts.iter().filter_map(|fact| fact.request_id.clone())) +} + +fn build_key(assignment_id: &str, ci_id: &str, facts: &[&DeploymentFact]) -> SccmDeploymentKey { + let ambiguous_topology = has_ambiguous_content_topology(facts); + 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: if ambiguous_topology { + SccmDeploymentKeyConfidence::Candidate + } else { + SccmDeploymentKeyConfidence::Exact + }, + extraction_profile_id: SCCM_DEPLOYMENT_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 opposing_outcomes_are_ambiguous( + facts: &[&DeploymentFact], + failure_kind: DeploymentFactKind, + success_kind: DeploymentFactKind, +) -> bool { + facts + .iter() + .copied() + .filter(|fact| fact.kind == failure_kind) + .any(|failure| { + facts + .iter() + .copied() + .filter(|fact| fact.kind == success_kind) + .any(|success| { + !matches!( + compare_fact_order(failure, success), + Some(Ordering::Less | Ordering::Greater) + ) + }) + }) +} + +/// 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_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 has_ambiguous_content_topology(facts) { + return uncertain( + SccmDeploymentPhase::LocateContent, + None, + REASON_TOPOLOGY_AMBIGUOUS, + FINDING_TOPOLOGY_AMBIGUOUS, + ); + } + + if opposing_outcomes_are_ambiguous( + facts, + DeploymentFactKind::IntentNotApplicable, + DeploymentFactKind::IntentTargeted, + ) { + return uncertain( + SccmDeploymentPhase::Intent, + None, + REASON_CHRONOLOGY, + FINDING_CHRONOLOGY_UNCERTAIN, + ); + } + + let not_applicable = unrecovered_failures( + facts, + DeploymentFactKind::IntentNotApplicable, + DeploymentFactKind::IntentTargeted, + ); + if let Some(not_applicable) = not_applicable.first().copied() { + 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); + + if opposing_outcomes_are_ambiguous( + facts, + DeploymentFactKind::RequirementsFailed, + DeploymentFactKind::RequirementsSatisfied, + ) || opposing_outcomes_are_ambiguous( + facts, + DeploymentFactKind::DependencyFailed, + DeploymentFactKind::RequirementsSatisfied, + ) { + return uncertain( + SccmDeploymentPhase::Requirements, + last, + REASON_CHRONOLOGY, + FINDING_CHRONOLOGY_UNCERTAIN, + ); + } + + let requirement_failures = unrecovered_failures( + facts, + DeploymentFactKind::RequirementsFailed, + DeploymentFactKind::RequirementsSatisfied, + ); + let dependency_failures = unrecovered_failures( + facts, + DeploymentFactKind::DependencyFailed, + DeploymentFactKind::RequirementsSatisfied, + ); + 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) { + Some(row) if row.state == SccmCoverageState::AccessDenied => { + REASON_LOCATION_ACCESS_DENIED + } + Some(row) + if row.state == SccmCoverageState::Capped + || (row.state == SccmCoverageState::Captured && !row.capture_complete) => + { + REASON_LOCATION_ROTATION + } + _ => REASON_LOCATION_ABSENT, + } + }; + return insufficient(SccmDeploymentPhase::LocateContent, last, reason, coverage); + }; + chain.push(located); + last = Some(SccmDeploymentPhase::LocateContent); + + if opposing_outcomes_are_ambiguous( + facts, + DeploymentFactKind::TransferFailed, + DeploymentFactKind::TransferCompleted, + ) { + return uncertain( + SccmDeploymentPhase::Transfer, + last, + REASON_CHRONOLOGY, + FINDING_CHRONOLOGY_UNCERTAIN, + ); + } + + 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); + + if opposing_outcomes_are_ambiguous( + facts, + DeploymentFactKind::CacheFailed, + DeploymentFactKind::CacheCommitted, + ) { + return uncertain( + SccmDeploymentPhase::Cache, + last, + REASON_CHRONOLOGY, + FINDING_CHRONOLOGY_UNCERTAIN, + ); + } + + 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); + + if opposing_outcomes_are_ambiguous( + facts, + DeploymentFactKind::EnforceFailed, + DeploymentFactKind::EnforceSucceeded, + ) { + return uncertain( + SccmDeploymentPhase::Enforce, + last, + REASON_CHRONOLOGY, + FINDING_CHRONOLOGY_UNCERTAIN, + ); + } + + 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 opposing_outcomes_are_ambiguous( + facts, + DeploymentFactKind::DetectionMismatch, + DeploymentFactKind::Detected, + ) { + return uncertain( + SccmDeploymentPhase::Detect, + last, + REASON_CHRONOLOGY, + FINDING_CHRONOLOGY_UNCERTAIN, + ); + } + + let detection_mismatches = unrecovered_failures( + facts, + DeploymentFactKind::DetectionMismatch, + DeploymentFactKind::Detected, + ); + if let Some(mismatch) = detection_mismatches + .iter() + .copied() + .find(|mismatch| { + chain + .last() + .is_none_or(|earlier| fact_is_strictly_before(earlier, mismatch)) + }) + .or_else(|| detection_mismatches.first().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); + + if opposing_outcomes_are_ambiguous( + facts, + DeploymentFactKind::ReportFailed, + DeploymentFactKind::ReportSucceeded, + ) { + return uncertain( + SccmDeploymentPhase::Report, + last, + REASON_CHRONOLOGY, + FINDING_CHRONOLOGY_UNCERTAIN, + ); + } + + 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(); + // A missing logical row is not evidence that collection was absent. Only + // an explicit non-captured row can authorize an InsufficientEvidence + // coverage finding; otherwise retain the bounded request as a symptom. + let physical_gap = coverage_for_group(coverage, group) + .is_some_and(|row| row.state != SccmCoverageState::Captured); + Outcome { + phase, + state: SccmDeploymentState::InsufficientEvidence, + classification: if physical_gap { + SccmDeploymentClassification::InsufficientEvidence + } else { + SccmDeploymentClassification::Symptom + }, + 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) + .filter(|_| physical_gap) + .map(|row| row.artifact_ids.clone()) + .unwrap_or_default(), + finding_id: Some(coverage_gap_finding_id(phase)), + terminal_evidence: Vec::new(), + } +} + +fn uncertain( + phase: SccmDeploymentPhase, + last_successful_phase: Option, + reason: &str, + finding_id: &'static str, +) -> Outcome { + 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.to_owned(), + }), + coverage_gap_artifact_ids: Vec::new(), + finding_id: Some(finding_id), + 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( + evidence: &[SccmEvidence], + artifacts_by_id: &BTreeMap<&str, &SccmArtifact>, + fact_artifact_ids: &BTreeSet<&str>, + incomplete_artifact_ids: &BTreeSet<&str>, +) -> Vec { + let mut evidence_by_artifact = BTreeMap::<&str, Vec<&SccmEvidence>>::new(); + for item in evidence.iter().filter(|item| item.role == SccmRole::Client) { + let artifact_id = item.reference.artifact_id.as_str(); + if !artifacts_by_id.contains_key(artifact_id) || !valid_reference(&item.reference) { + continue; + } + evidence_by_artifact + .entry(artifact_id) + .or_default() + .push(item); + } + + evidence_by_artifact + .into_iter() + .filter_map(|(artifact_id, evidence)| { + let _artifact = artifacts_by_id.get(artifact_id)?; + let incomplete_capture = incomplete_artifact_ids.contains(artifact_id); + if fact_artifact_ids.contains(artifact_id) && !incomplete_capture { + return None; + } + let start = evidence + .iter() + .filter_map(|item| item.reference.line_start) + .min()?; + let end = evidence + .iter() + .filter_map(|item| item.reference.line_end) + .max()?; + let key_confidence = if evidence.iter().any(|item| has_candidate_key(&item.message)) { + SccmDeploymentObservationKeyConfidence::Candidate + } else { + SccmDeploymentObservationKeyConfidence::None + }; + + Some(SccmDeploymentObservation { + observation_id: format!("supplemental:{artifact_id}"), + artifact_id: artifact_id.to_owned(), + complete_logical_record: true, + key_confidence, + confidence_ceiling: SccmDeploymentConfidence::Low, + correlation_eligible: false, + reason: if incomplete_capture { + "incomplete physical capture cannot prove a complete deployment workflow" + .to_owned() + } else { + "unvalidated complete record cannot override an exact keyed client transaction" + .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() +} + +/// 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"; +const FINDING_TOPOLOGY_AMBIGUOUS: &str = "deployment-content-topology-ambiguous"; + +/// 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.", + ), + FINDING_TOPOLOGY_AMBIGUOUS => ( + "Client content topology is ambiguous", + "Conflicting content identities were recorded for this assignment and CI, so no deployment outcome or server handoff 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(); + + 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/health.rs b/crates/cmtraceopen-parser/src/sccm/client/health.rs new file mode 100644 index 000000000..4e89279bc --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/health.rs @@ -0,0 +1,953 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::ops::Deref; + +use serde::Serialize; +use thiserror::Error; + +use crate::models::log_entry::Severity; +use crate::sccm::{ + SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, SccmCorrelationKey, + SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFinding, + SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, SccmFindingValidationError, + SccmPhase, SccmRole, SccmRotation, SccmTerminalEvidence, +}; + +use super::admission::SccmClientAdmittedSourceArtifact; +use super::{SccmClientAdmittedEvidence, SccmClientEvidenceAdmissionError}; + +pub const SCCM_CLIENT_HEALTH_ANALYSIS_SCHEMA_VERSION: u32 = 1; + +type EvidenceIdentity = (String, String, Option, Option); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientHealthPhase { + Install, + Upgrade, + Repair, + Removal, + Service, + ClientHealth, + Reboot, + Identity, + Authentication, + Assignment, + Boundary, + ManagementPointLocation, + Transport, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientHealthHopState { + Succeeded, + Failed, + Pending, + Contradictory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientHealthHop { + pub phase: SccmClientHealthPhase, + pub state: SccmClientHealthHopState, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientHealthSourceCoverage { + pub artifact_id: String, + pub logical_artifact_id: String, + pub coverage: SccmCoverageState, + pub rotation: SccmRotation, + pub fragment_complete: bool, + pub physical: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientHealthFinding { + #[serde(flatten)] + pub finding: SccmFinding, + pub health_phase: SccmClientHealthPhase, + pub last_confirmed_successful_phase: Option, +} + +impl Deref for SccmClientHealthFinding { + type Target = SccmFinding; + + fn deref(&self) -> &Self::Target { + &self.finding + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientHealthAnalysis { + pub schema_version: u32, + pub lifecycle_phase: Option, + pub last_confirmed_successful_phase: Option, + pub hops: Vec, + pub findings: Vec, + pub source_coverage: Vec, + pub prohibited_claims: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SccmClientHealthAnalysisError { + #[error(transparent)] + EvidenceAdmission(#[from] SccmClientEvidenceAdmissionError), + #[error("client health reducer produced an invalid canonical finding: {0:?}")] + FindingContract(SccmFindingValidationError), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Disposition { + Started, + Succeeded, + Failed, + Pending, +} + +#[derive(Debug, Clone, Default)] +struct FactKeys { + client_guid: Option, + site_code: Option, + management_point_host: Option, + request_id: Option, +} + +#[derive(Debug, Clone)] +struct HealthFact { + phase: SccmClientHealthPhase, + disposition: Disposition, + terminal: bool, + keys: FactKeys, + reference: SccmEvidenceRef, + utc_millis: i64, +} + +#[derive(Debug, Clone, Default)] +struct ChainKeys { + client_guid: Option, + site_code: Option, + management_point_host: Option, + last_utc_millis: Option, +} + +#[derive(Debug)] +enum Resolution<'a> { + Succeeded(&'a HealthFact), + Failed(&'a HealthFact), + Pending(Vec<&'a HealthFact>), + Contradictory(Vec<&'a HealthFact>), + Missing, +} + +pub fn analyze_client_health( + admitted: &SccmClientAdmittedEvidence, +) -> Result { + let evidence = admitted.evidence()?; + let sources = admitted.source_artifacts()?; + let source_coverage = health_source_coverage(sources); + let keys_by_reference = admitted_health_keys(admitted, evidence, sources)?; + let mut facts = evidence + .iter() + .filter_map(|evidence| { + let source = sources.get(&evidence.reference.artifact_id)?; + let keys = keys_by_reference + .get(&reference_identity(&evidence.reference)) + .cloned() + .unwrap_or_default(); + parse_fact(evidence, source, keys) + }) + .collect::>(); + facts.sort_by(|left, right| { + (left.utc_millis, reference_identity(&left.reference)) + .cmp(&(right.utc_millis, reference_identity(&right.reference))) + }); + + let mut hops = Vec::new(); + let mut chain = ChainKeys::default(); + let mut last_success = None; + let lifecycle = lifecycle_resolution(&facts); + let lifecycle_phase = lifecycle_phase(&lifecycle); + + match lifecycle { + Resolution::Succeeded(fact) => { + advance_chain(&mut chain, fact); + last_success = Some(fact.phase); + hops.push(hop(fact.phase, SccmClientHealthHopState::Succeeded, [fact])); + } + stop => { + return finish_at_stop( + lifecycle_phase.unwrap_or(SccmClientHealthPhase::Install), + stop, + last_success, + hops, + source_coverage, + evidence, + sources, + ); + } + } + + for phase in ordered_post_lifecycle_phases() { + let resolution = resolve_phase(&facts, phase, &chain); + match resolution { + Resolution::Succeeded(fact) => { + advance_chain(&mut chain, fact); + last_success = Some(phase); + hops.push(hop(phase, SccmClientHealthHopState::Succeeded, [fact])); + } + stop => { + return finish_at_stop( + phase, + stop, + last_success, + hops, + source_coverage, + evidence, + sources, + ); + } + } + } + + Ok(SccmClientHealthAnalysis { + schema_version: SCCM_CLIENT_HEALTH_ANALYSIS_SCHEMA_VERSION, + lifecycle_phase, + last_confirmed_successful_phase: last_success, + hops, + findings: Vec::new(), + source_coverage, + prohibited_claims: prohibited_claims(), + }) +} + +fn finish_at_stop( + phase: SccmClientHealthPhase, + resolution: Resolution<'_>, + last_success: Option, + mut hops: Vec, + source_coverage: Vec, + evidence: &[SccmEvidence], + sources: &BTreeMap, +) -> Result { + let (state, facts) = match resolution { + Resolution::Failed(fact) => (SccmClientHealthHopState::Failed, vec![fact]), + Resolution::Pending(facts) => (SccmClientHealthHopState::Pending, facts), + Resolution::Contradictory(facts) => (SccmClientHealthHopState::Contradictory, facts), + Resolution::Missing => (SccmClientHealthHopState::Pending, Vec::new()), + Resolution::Succeeded(_) => unreachable!("successful phases do not stop the chain"), + }; + if !facts.is_empty() { + hops.push(hop(phase, state, facts.iter().copied())); + } + let finding = finding_for_stop(phase, state, &facts, last_success, evidence, sources)?; + + Ok(SccmClientHealthAnalysis { + schema_version: SCCM_CLIENT_HEALTH_ANALYSIS_SCHEMA_VERSION, + lifecycle_phase: hops + .first() + .map(|hop| hop.phase) + .filter(|phase| phase.is_lifecycle()), + last_confirmed_successful_phase: last_success, + hops, + findings: vec![finding], + source_coverage, + prohibited_claims: prohibited_claims(), + }) +} + +fn finding_for_stop( + phase: SccmClientHealthPhase, + state: SccmClientHealthHopState, + facts: &[&HealthFact], + last_success: Option, + evidence: &[SccmEvidence], + sources: &BTreeMap, +) -> Result { + let logical_id = logical_artifact_for_phase(phase); + let coverage_gaps = sources + .iter() + .filter(|(_, source)| { + logical_artifact_for_basename(&source.basename) == Some(logical_id) + && !source_is_complete(source) + }) + .map(|(artifact_id, source)| SccmFindingCoverageGap { + artifact_id: artifact_id.clone(), + role: SccmRole::Client, + coverage: if source.coverage == SccmCoverageState::Captured { + SccmCoverageState::Capped + } else { + source.coverage.clone() + }, + }) + .collect::>(); + let mut cited = facts + .iter() + .map(|fact| fact.reference.clone()) + .collect::>(); + if cited.is_empty() && coverage_gaps.is_empty() { + cited = evidence + .iter() + .filter(|evidence| { + sources + .get(&evidence.reference.artifact_id) + .is_some_and(|source| { + logical_artifact_for_basename(&source.basename) == Some(logical_id) + }) + }) + .map(|evidence| evidence.reference.clone()) + .collect(); + } + cited.sort_by_key(reference_identity); + cited.dedup(); + + let (class, severity, confidence, title, summary) = if state == SccmClientHealthHopState::Failed + { + ( + SccmFindingClass::ConfirmedFailure, + Severity::Error, + SccmConfidence::High, + format!( + "Client health {} recorded a terminal failure", + phase.display_name() + ), + format!( + "Admitted client evidence recorded a terminal failure at the {} phase.", + phase.display_name() + ), + ) + } else if !coverage_gaps.is_empty() { + ( + SccmFindingClass::InsufficientEvidence, + Severity::Warning, + SccmConfidence::Low, + format!( + "Client health {} evidence is incomplete", + phase.display_name() + ), + format!( + "The {} phase cannot be evaluated because its exact source coverage is incomplete.", + phase.display_name() + ), + ) + } else { + ( + SccmFindingClass::Symptom, + Severity::Warning, + SccmConfidence::Low, + format!("Client health {} outcome is not confirmed", phase.display_name()), + format!( + "The admitted {} source does not contain one unambiguous terminal outcome for the exact chain key.", + phase.display_name() + ), + ) + }; + + let terminal_evidence = if state == SccmClientHealthHopState::Failed { + facts + .iter() + .map(|fact| SccmTerminalEvidence::observed_failure(fact.reference.clone())) + .collect::>() + } else { + Vec::new() + }; + let finding = + SccmFindingBuilder::new(format!("client-health-{}-stop", phase.serialized_name())) + .class(class) + .phase(SccmPhase::Unknown(phase.serialized_name().to_owned())) + .role(SccmRole::Client) + .severity(severity) + .confidence(confidence) + .title(title) + .summary(summary) + .evidence(cited) + .terminal_evidence(terminal_evidence) + .coverage_gaps(coverage_gaps) + .next_artifact(request_for_phase(phase)) + .build() + .map_err(SccmClientHealthAnalysisError::FindingContract)?; + + Ok(SccmClientHealthFinding { + finding, + health_phase: phase, + last_confirmed_successful_phase: last_success, + }) +} + +fn admitted_health_keys( + admitted: &SccmClientAdmittedEvidence, + evidence: &[SccmEvidence], + sources: &BTreeMap, +) -> Result, SccmClientHealthAnalysisError> { + let evidence_ids = evidence + .iter() + .map(|item| reference_identity(&item.reference)) + .collect::>(); + let mut by_reference = BTreeMap::<_, FactKeys>::new(); + for (artifact_id, source) in sources { + if !source_is_complete(source) || logical_artifact_for_basename(&source.basename).is_none() + { + continue; + } + let extraction = admitted.extract_keys_for_artifact(artifact_id)?; + if extraction.artifact_id() != artifact_id { + return Err(SccmClientEvidenceAdmissionError::IntegrityViolation.into()); + } + if artifact_family_for_basename(&source.basename) + .is_none_or(|family| extraction.artifact_family() != &family) + { + return Err(SccmClientEvidenceAdmissionError::IntegrityViolation.into()); + } + for result in extraction.results() { + for key in &result.keys { + let Some(reference) = key.evidence.as_ref() else { + continue; + }; + let identity = reference_identity(reference); + if !evidence_ids.contains(&identity) { + return Err(SccmClientEvidenceAdmissionError::IntegrityViolation.into()); + } + apply_key(by_reference.entry(identity).or_default(), key); + } + } + } + Ok(by_reference) +} + +fn apply_key(keys: &mut FactKeys, key: &SccmCorrelationKey) { + let target = match key.kind { + SccmCorrelationKeyKind::ClientGuid => &mut keys.client_guid, + SccmCorrelationKeyKind::SiteCode => &mut keys.site_code, + SccmCorrelationKeyKind::ServerHost => &mut keys.management_point_host, + SccmCorrelationKeyKind::RequestId => &mut keys.request_id, + _ => return, + }; + if target.is_none() { + *target = Some(key.normalized.clone()); + } +} + +fn parse_fact( + evidence: &SccmEvidence, + source: &SccmClientAdmittedSourceArtifact, + keys: FactKeys, +) -> Option { + if !source_is_complete(source) { + return None; + } + let fields = parse_unique_fields(&evidence.message)?; + if !fields + .get("family") + .is_some_and(|value| value.eq_ignore_ascii_case("health")) + { + return None; + } + let phase = fields.get("phase").and_then(|value| parse_phase(value))?; + if !source_allows_phase(&source.basename, phase) || !required_keys_present(phase, &keys) { + return None; + } + let disposition = fields + .get("disposition") + .and_then(|value| parse_disposition(value))?; + let terminal = fields + .get("terminal") + .is_some_and(|value| value.eq_ignore_ascii_case("true")); + if matches!(disposition, Disposition::Succeeded | Disposition::Failed) != terminal { + return None; + } + Some(HealthFact { + phase, + disposition, + terminal, + keys, + reference: evidence.reference.clone(), + utc_millis: evidence.timestamp.utc_millis?, + }) +} + +fn parse_unique_fields(message: &str) -> Option> { + let mut fields = BTreeMap::new(); + for token in message.split_whitespace() { + let Some((label, value)) = token.split_once('=') else { + continue; + }; + if value.is_empty() + || fields + .insert(label.to_ascii_lowercase(), value.to_owned()) + .is_some() + { + return None; + } + } + Some(fields) +} + +fn lifecycle_resolution(facts: &[HealthFact]) -> Resolution<'_> { + let lifecycle = facts + .iter() + .filter(|fact| fact.phase.is_lifecycle()) + .collect::>(); + resolve_candidates(lifecycle, None, false) +} + +fn lifecycle_phase(resolution: &Resolution<'_>) -> Option { + match resolution { + Resolution::Succeeded(fact) | Resolution::Failed(fact) => Some(fact.phase), + Resolution::Pending(facts) | Resolution::Contradictory(facts) => { + facts.first().map(|fact| fact.phase) + } + Resolution::Missing => None, + } +} + +fn resolve_phase<'a>( + facts: &'a [HealthFact], + phase: SccmClientHealthPhase, + chain: &ChainKeys, +) -> Resolution<'a> { + let matching_candidates = facts + .iter() + .filter(|fact| fact.phase == phase && fact_matches_chain(fact, chain)) + .collect::>(); + let Some(newest_matching_utc_millis) = + matching_candidates.iter().map(|fact| fact.utc_millis).max() + else { + return Resolution::Missing; + }; + let candidates = facts + .iter() + .filter(|fact| { + fact.phase == phase + && (fact_matches_chain(fact, chain) + || fact.utc_millis == newest_matching_utc_millis) + }) + .collect::>(); + resolve_candidates( + candidates, + Some(phase), + phase == SccmClientHealthPhase::Transport, + ) +} + +fn resolve_candidates<'a>( + candidates: Vec<&'a HealthFact>, + phase: Option, + transport: bool, +) -> Resolution<'a> { + if candidates.is_empty() { + return Resolution::Missing; + } + if phase.is_none() + && candidates + .iter() + .filter_map(|fact| fact.keys.client_guid.as_deref()) + .collect::>() + .len() + != 1 + { + return Resolution::Contradictory(candidates); + } + let newest_utc_millis = candidates + .iter() + .map(|fact| fact.utc_millis) + .max() + .expect("candidate set is nonempty"); + let newest = candidates + .iter() + .copied() + .filter(|fact| fact.utc_millis == newest_utc_millis) + .collect::>(); + if phase.is_none() + && newest + .iter() + .map(|fact| fact.phase) + .collect::>() + .len() + != 1 + { + return Resolution::Contradictory(newest); + } + let newest_phase = phase.unwrap_or(newest[0].phase); + if newest + .iter() + .any(|fact| !same_phase_correlation_tuple(newest[0], fact, newest_phase)) + { + return Resolution::Contradictory(newest); + } + let newest_disposition = newest[0].disposition; + if newest + .iter() + .any(|fact| fact.disposition != newest_disposition) + { + return Resolution::Contradictory(newest); + } + let last = *newest + .iter() + .max_by_key(|fact| reference_identity(&fact.reference)) + .expect("newest candidate set is nonempty"); + if !last.terminal { + return Resolution::Pending(newest); + } + if transport { + let started = candidates.iter().any(|fact| { + fact.disposition == Disposition::Started + && fact.utc_millis < last.utc_millis + && fact.keys.request_id == last.keys.request_id + && fact.keys.management_point_host == last.keys.management_point_host + }); + if !started { + return Resolution::Pending(candidates); + } + } + match last.disposition { + Disposition::Succeeded => Resolution::Succeeded(last), + Disposition::Failed => Resolution::Failed(last), + Disposition::Started | Disposition::Pending => Resolution::Pending(candidates), + } +} + +fn same_phase_correlation_tuple( + left: &HealthFact, + right: &HealthFact, + phase: SccmClientHealthPhase, +) -> bool { + match phase { + phase if phase.is_lifecycle() => left.keys.client_guid == right.keys.client_guid, + SccmClientHealthPhase::Service + | SccmClientHealthPhase::ClientHealth + | SccmClientHealthPhase::Reboot + | SccmClientHealthPhase::Identity + | SccmClientHealthPhase::Authentication => left.keys.client_guid == right.keys.client_guid, + SccmClientHealthPhase::Assignment | SccmClientHealthPhase::Boundary => { + left.keys.client_guid == right.keys.client_guid + && left.keys.site_code == right.keys.site_code + } + SccmClientHealthPhase::ManagementPointLocation => { + left.keys.site_code == right.keys.site_code + && left.keys.management_point_host == right.keys.management_point_host + } + SccmClientHealthPhase::Transport => { + left.keys.management_point_host == right.keys.management_point_host + && left.keys.request_id == right.keys.request_id + } + _ => false, + } +} + +fn fact_matches_chain(fact: &HealthFact, chain: &ChainKeys) -> bool { + if chain + .last_utc_millis + .is_some_and(|last_utc_millis| fact.utc_millis <= last_utc_millis) + { + return false; + } + match fact.phase { + SccmClientHealthPhase::Service + | SccmClientHealthPhase::ClientHealth + | SccmClientHealthPhase::Reboot + | SccmClientHealthPhase::Identity + | SccmClientHealthPhase::Authentication + | SccmClientHealthPhase::Assignment + | SccmClientHealthPhase::Boundary => fact.keys.client_guid == chain.client_guid, + SccmClientHealthPhase::ManagementPointLocation => { + fact.keys.site_code == chain.site_code && optional_client_matches(fact, chain) + } + SccmClientHealthPhase::Transport => { + fact.keys.management_point_host == chain.management_point_host + && optional_client_matches(fact, chain) + } + phase if phase.is_lifecycle() => true, + _ => false, + } +} + +fn optional_client_matches(fact: &HealthFact, chain: &ChainKeys) -> bool { + fact.keys + .client_guid + .as_ref() + .is_none_or(|client_guid| Some(client_guid) == chain.client_guid.as_ref()) +} + +fn advance_chain(chain: &mut ChainKeys, fact: &HealthFact) { + if fact.keys.client_guid.is_some() { + chain.client_guid = fact.keys.client_guid.clone(); + } + if fact.keys.site_code.is_some() { + chain.site_code = fact.keys.site_code.clone(); + } + if fact.keys.management_point_host.is_some() { + chain.management_point_host = fact.keys.management_point_host.clone(); + } + chain.last_utc_millis = Some(fact.utc_millis); +} + +fn required_keys_present(phase: SccmClientHealthPhase, keys: &FactKeys) -> bool { + match phase { + phase if phase.is_lifecycle() => keys.client_guid.is_some(), + SccmClientHealthPhase::Service + | SccmClientHealthPhase::ClientHealth + | SccmClientHealthPhase::Reboot + | SccmClientHealthPhase::Identity + | SccmClientHealthPhase::Authentication => keys.client_guid.is_some(), + SccmClientHealthPhase::Assignment | SccmClientHealthPhase::Boundary => { + keys.client_guid.is_some() && keys.site_code.is_some() + } + SccmClientHealthPhase::ManagementPointLocation => { + keys.site_code.is_some() && keys.management_point_host.is_some() + } + SccmClientHealthPhase::Transport => { + keys.management_point_host.is_some() && keys.request_id.is_some() + } + _ => false, + } +} + +fn health_source_coverage( + sources: &BTreeMap, +) -> Vec { + sources + .iter() + .filter_map(|(artifact_id, source)| { + Some(SccmClientHealthSourceCoverage { + artifact_id: artifact_id.clone(), + logical_artifact_id: logical_artifact_for_basename(&source.basename)?.to_owned(), + coverage: source.coverage.clone(), + rotation: source.rotation.clone(), + fragment_complete: source.fragment_complete == Some(true), + physical: source.physical, + }) + }) + .collect() +} + +fn source_is_complete(source: &SccmClientAdmittedSourceArtifact) -> bool { + source.coverage == SccmCoverageState::Captured + && source.fragment_complete == Some(true) + && source.physical +} + +fn source_allows_phase(basename: &str, phase: SccmClientHealthPhase) -> bool { + match canonical_basename(basename) { + "ccmsetup.log" | "client.msi.log" => phase.is_lifecycle(), + "CcmEval.log" | "CcmExec.log" => matches!( + phase, + SccmClientHealthPhase::Service + | SccmClientHealthPhase::ClientHealth + | SccmClientHealthPhase::Reboot + ), + "CcmRestart.log" => phase == SccmClientHealthPhase::Reboot, + "ClientIDManagerStartup.log" => matches!( + phase, + SccmClientHealthPhase::Identity | SccmClientHealthPhase::Authentication + ), + "LocationServices.log" | "ClientLocation.log" => matches!( + phase, + SccmClientHealthPhase::Assignment + | SccmClientHealthPhase::Boundary + | SccmClientHealthPhase::ManagementPointLocation + | SccmClientHealthPhase::Transport + ), + "CcmMessaging.log" => phase == SccmClientHealthPhase::Transport, + _ => false, + } +} + +fn logical_artifact_for_basename(basename: &str) -> Option<&'static str> { + match canonical_basename(basename) { + "ccmsetup.log" | "client.msi.log" => Some("client-ccmsetup"), + "CcmEval.log" | "CcmExec.log" | "CcmRestart.log" => Some("client-evaluation"), + "ClientIDManagerStartup.log" => Some("client-identity"), + "LocationServices.log" | "ClientLocation.log" | "CcmMessaging.log" => { + Some("client-location") + } + _ => None, + } +} + +fn artifact_family_for_basename(basename: &str) -> Option { + match canonical_basename(basename) { + "ccmsetup.log" | "client.msi.log" => Some(SccmArtifactFamily::ClientSetup), + "CcmEval.log" | "CcmExec.log" | "CcmRestart.log" => Some(SccmArtifactFamily::ClientHealth), + "ClientIDManagerStartup.log" => Some(SccmArtifactFamily::ClientIdentity), + "LocationServices.log" | "ClientLocation.log" | "CcmMessaging.log" => { + Some(SccmArtifactFamily::ClientLocation) + } + _ => None, + } +} + +fn canonical_basename(basename: &str) -> &str { + match basename { + "ccmsetup.lo_" => "ccmsetup.log", + "client.msi.lo_" => "client.msi.log", + "CcmEval.lo_" => "CcmEval.log", + "CcmExec.lo_" => "CcmExec.log", + "CcmRestart.lo_" => "CcmRestart.log", + "ClientIDManagerStartup.lo_" => "ClientIDManagerStartup.log", + "LocationServices.lo_" => "LocationServices.log", + "ClientLocation.lo_" => "ClientLocation.log", + "CcmMessaging.lo_" => "CcmMessaging.log", + other => other, + } +} + +fn logical_artifact_for_phase(phase: SccmClientHealthPhase) -> &'static str { + match phase { + phase if phase.is_lifecycle() => "client-ccmsetup", + SccmClientHealthPhase::Service + | SccmClientHealthPhase::ClientHealth + | SccmClientHealthPhase::Reboot => "client-evaluation", + SccmClientHealthPhase::Identity | SccmClientHealthPhase::Authentication => { + "client-identity" + } + SccmClientHealthPhase::Assignment + | SccmClientHealthPhase::Boundary + | SccmClientHealthPhase::ManagementPointLocation + | SccmClientHealthPhase::Transport => "client-location", + _ => unreachable!("all health phases are mapped"), + } +} + +fn request_for_phase(phase: SccmClientHealthPhase) -> SccmArtifactRequest { + let (logical_id, basename) = match logical_artifact_for_phase(phase) { + "client-ccmsetup" => ("ccmSetup", "ccmsetup"), + "client-evaluation" => ("ccmEval", "CcmEval"), + "client-identity" => ("clientIdManagerStartup", "ClientIDManagerStartup"), + "client-location" => ("locationServices", "LocationServices"), + _ => unreachable!("health request mapping is closed"), + }; + SccmArtifactRequest { + logical_id: logical_id.to_owned(), + role: SccmRole::Client, + reason: format!("Confirm the root cause recorded by {basename}."), + } +} + +fn hop<'a>( + phase: SccmClientHealthPhase, + state: SccmClientHealthHopState, + facts: impl IntoIterator, +) -> SccmClientHealthHop { + let mut evidence = facts + .into_iter() + .map(|fact| fact.reference.clone()) + .collect::>(); + evidence.sort_by_key(reference_identity); + evidence.dedup(); + SccmClientHealthHop { + phase, + state, + evidence, + } +} + +fn reference_identity(reference: &SccmEvidenceRef) -> EvidenceIdentity { + ( + reference.artifact_id.clone(), + reference.entry_id.clone(), + reference.line_start, + reference.line_end, + ) +} + +fn parse_phase(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "install" => Some(SccmClientHealthPhase::Install), + "upgrade" => Some(SccmClientHealthPhase::Upgrade), + "repair" => Some(SccmClientHealthPhase::Repair), + "removal" => Some(SccmClientHealthPhase::Removal), + "service" => Some(SccmClientHealthPhase::Service), + "clienthealth" => Some(SccmClientHealthPhase::ClientHealth), + "reboot" => Some(SccmClientHealthPhase::Reboot), + "identityregistration" => Some(SccmClientHealthPhase::Identity), + "authentication" => Some(SccmClientHealthPhase::Authentication), + "assignment" => Some(SccmClientHealthPhase::Assignment), + "boundary" => Some(SccmClientHealthPhase::Boundary), + "managementpointlocation" => Some(SccmClientHealthPhase::ManagementPointLocation), + "transport" => Some(SccmClientHealthPhase::Transport), + _ => None, + } +} + +fn parse_disposition(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "started" => Some(Disposition::Started), + "succeeded" => Some(Disposition::Succeeded), + "failed" => Some(Disposition::Failed), + "pending" | "deferred" => Some(Disposition::Pending), + _ => None, + } +} + +fn ordered_post_lifecycle_phases() -> [SccmClientHealthPhase; 9] { + [ + SccmClientHealthPhase::Service, + SccmClientHealthPhase::ClientHealth, + SccmClientHealthPhase::Reboot, + SccmClientHealthPhase::Identity, + SccmClientHealthPhase::Authentication, + SccmClientHealthPhase::Assignment, + SccmClientHealthPhase::Boundary, + SccmClientHealthPhase::ManagementPointLocation, + SccmClientHealthPhase::Transport, + ] +} + +impl SccmClientHealthPhase { + fn is_lifecycle(self) -> bool { + matches!( + self, + Self::Install | Self::Upgrade | Self::Repair | Self::Removal + ) + } + + fn serialized_name(self) -> &'static str { + match self { + Self::Install => "install", + Self::Upgrade => "upgrade", + Self::Repair => "repair", + Self::Removal => "removal", + Self::Service => "service", + Self::ClientHealth => "clientHealth", + Self::Reboot => "reboot", + Self::Identity => "identityRegistration", + Self::Authentication => "authentication", + Self::Assignment => "assignment", + Self::Boundary => "boundary", + Self::ManagementPointLocation => "managementPointLocation", + Self::Transport => "transport", + } + } + + fn display_name(self) -> &'static str { + match self { + Self::Install => "install", + Self::Upgrade => "upgrade", + Self::Repair => "repair", + Self::Removal => "removal", + Self::Service => "service", + Self::ClientHealth => "client health", + Self::Reboot => "reboot", + Self::Identity => "identity registration", + Self::Authentication => "authentication", + Self::Assignment => "assignment", + Self::Boundary => "boundary", + Self::ManagementPointLocation => "management point location", + Self::Transport => "transport", + } + } +} + +fn prohibited_claims() -> Vec { + vec![ + "server root cause".to_owned(), + "isolated error proves terminal failure".to_owned(), + "missing source proves workflow failure".to_owned(), + "host path or raw sensitive text export".to_owned(), + ] +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs new file mode 100644 index 000000000..851854833 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -0,0 +1,2101 @@ +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{ + de::{DeserializeSeed, Error as _, IgnoredAny, MapAccess, SeqAccess, Visitor}, + ser::{Error as _, SerializeStruct}, + Deserialize, Deserializer, Serialize, Serializer, +}; +use thiserror::Error; + +use crate::sccm::catalog::{ + classify_artifact_name, declared_client_source_memberships, SccmClientSourceMembership, +}; +use crate::sccm::{ + SccmArtifact, SccmCoverageState, SccmRole, SccmRotation, SCCM_DIAGNOSTICS_SCHEMA_VERSION, +}; + +/// Maximum artifact declarations admitted by one SCCM client intake bundle. +/// +/// This is the authoritative v1 ceiling for both pure client intake and the +/// native SCCM client manifest/capture boundary. Native readers, writers, and +/// collectors must import and reuse this constant rather than define a wider +/// or otherwise parallel limit. Pure intake validates it before allocating +/// per-artifact indexes so a malformed bundle cannot turn validation into an +/// unbounded allocation path. +pub const MAX_SCCM_CLIENT_INTAKE_ARTIFACTS: usize = 4096; + +const MAX_ARTIFACT_ID_CHARS: usize = 160; +const MAX_BASENAME_CHARS: usize = 160; +const MAX_COLLECTED_AT_CHARS: usize = 64; +const MAX_PATH_IDENTITY_CHARS: usize = 512; +const MAX_SYNTHETIC_FINGERPRINT_TOKENS: usize = 10; +const NATIVE_ARTIFACT_ID_PREFIX_V1: &str = "sccm-artifact:v1:sha256:"; +const OPAQUE_UNSUPPORTED_BASENAME_PREFIX_V1: &str = "sccm-unknown-v1-sha256-"; +const OPAQUE_ROTATION_KIND_V1: &str = "cmtraceopen.rotation.opaque.v1"; +const REVIEWED_UNSUPPORTED_SYNTHETIC_BASENAMES: &[&str] = &[ + "CustomVendorHook.log", + "CustomVendorHook.lo_", + "PolicyAgent.log.backup", +]; +// Synthetic fingerprints are fixture-only provenance. Keep their vocabulary +// finite so the public field cannot become an arbitrary user/context channel. +// Extending this list is a privacy-contract change that requires review. +const SYNTHETIC_FINGERPRINT_TOKENS: &[&str] = &[ + "a", + "absent", + "access", + "agent", + "app", + "approved", + "artifact", + "auth", + "b", + "basename", + "bits", + "boundary", + "c", + "cache", + "candidate", + "capped", + "ccmsetup", + "client", + "collision", + "complete", + "completeness", + "content", + "contradictory", + "current", + "custom", + "deferred", + "denied", + "dependency", + "deployment", + "detect", + "detection", + "download", + "dp", + "enforce", + "enforcement", + "evaluate", + "evaluation", + "exit", + "failure", + "false", + "fingerprint", + "gate", + "health", + "identity", + "incomplete", + "intent", + "invalid", + "lo", + "location", + "lookalike", + "malformed", + "missing", + "mp", + "multiline", + "negative", + "no", + "not", + "numbered", + "offset", + "one", + "or", + "path", + "persist", + "policy", + "recovery", + "relative", + "report", + "reporting", + "requirements", + "root", + "rotation", + "rotations", + "scheduler", + "services", + "setup", + "site", + "state", + "success", + "supplemental", + "targeted", + "time", + "transfer", + "transport", + "two", + "unknown", + "unsafe", + "update", + "updates", + "valid", + "version", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientWorkflow { + Health, + Policy, + Deployment, + TaskSequence, + Updates, + Inventory, + Compliance, + Metering, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTaskSequencePathClass { + #[serde(rename = "winpe")] + WinPe, + Setup, + FullOs, + Client, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientSourceRequiredness { + Required, + Supplemental, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientSourceGroupDefinition { + pub logical_artifact_id: String, + pub accepted_basenames: Vec, + pub workflows: Vec, + pub requiredness: SccmClientSourceRequiredness, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SccmClientIntakeArtifact { + pub artifact: SccmArtifact, + /// Collision-safe path identity. Mandatory for physical states; a + /// non-physical marker may also carry one to pin which configured + /// location the marker refers to (for example, absent under two + /// sibling roots declared as distinct missing locations). A marker and a + /// physical declaration may share basename and rotation only when both + /// carry distinct configured-root fingerprints. An unpinned marker claims + /// every configured root for that source and therefore collides with any + /// physical declaration for it. + pub path_fingerprint: Option, + /// Versioned, privacy-safe identity shared by rotations of one physical + /// source. Optional for compatibility with pre-lineage intake values; + /// repeating a path fingerprint requires an explicit matching lineage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rotation_lineage: Option, + pub relative_path: Option, + /// Whether the captured bytes begin and end on complete logical-record + /// boundaries. This is independent of normalization success: a + /// `ParseFailed` fragment may be complete when all bytes were copied but + /// their contents could not be normalized as CCM evidence. + pub fragment_complete: Option, + /// Byte length declared by the capture authority for a recognized + /// `Captured` fragment. Admission requires it together with + /// `content_sha256` before caller-supplied bytes can become evidence. Task + /// Sequence rotation fragments may bind incomplete physical bytes for a + /// source-local citation; other source families require complete framing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub declared_byte_length: Option, + /// Lowercase SHA-256 declared by the capture authority. It is a pair with + /// `declared_byte_length` and is forbidden on noncaptured or unsupported + /// declarations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_sha256: Option, +} + +/// A coverage-only declaration for a recognized client source rotation that +/// was intentionally not retained. This is distinct from a physical artifact: +/// it never contains a bundle-relative path, bytes, or fragment-boundary +/// claim. The versioned opaque identity, configured-source fingerprint, and +/// rotation lineage retain only the provenance needed to prevent collisions. +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientIntakeCaptureGap { + pub artifact_id: String, + pub basename: String, + pub rotation: SccmRotation, + pub coverage: SccmCoverageState, + pub path_fingerprint: String, + pub rotation_lineage: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmClientIntakeCaptureGapWire { + artifact_id: String, + basename: String, + rotation: SccmRotation, + coverage: SccmCoverageState, + path_fingerprint: String, + rotation_lineage: String, +} + +impl From for SccmClientIntakeCaptureGap { + fn from(wire: SccmClientIntakeCaptureGapWire) -> Self { + Self { + artifact_id: wire.artifact_id, + basename: wire.basename, + rotation: wire.rotation, + coverage: wire.coverage, + path_fingerprint: wire.path_fingerprint, + rotation_lineage: wire.rotation_lineage, + } + } +} + +impl From<&SccmClientIntakeCaptureGap> for SccmClientIntakeCaptureGapWire { + fn from(capture_gap: &SccmClientIntakeCaptureGap) -> Self { + Self { + artifact_id: capture_gap.artifact_id.clone(), + basename: capture_gap.basename.clone(), + rotation: capture_gap.rotation.clone(), + coverage: capture_gap.coverage.clone(), + path_fingerprint: capture_gap.path_fingerprint.clone(), + rotation_lineage: capture_gap.rotation_lineage.clone(), + } + } +} + +impl Serialize for SccmClientIntakeCaptureGap { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + validate_capture_gap_shape(self).map_err(S::Error::custom)?; + SccmClientIntakeCaptureGapWire::from(self).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SccmClientIntakeCaptureGap { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let capture_gap = Self::from(SccmClientIntakeCaptureGapWire::deserialize(deserializer)?); + validate_capture_gap_shape(&capture_gap).map_err(D::Error::custom)?; + Ok(capture_gap) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientIntakeBundle { + pub artifacts: Vec, + /// Additive v1 coverage-only declarations. Empty remains omitted on the + /// wire so pre-gap bundle JSON round-trips unchanged. + pub capture_gaps: Vec, +} + +impl Serialize for SccmClientIntakeBundle { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + validate_bundle(self).map_err(S::Error::custom)?; + + let field_count = 1 + usize::from(!self.capture_gaps.is_empty()); + let mut state = serializer.serialize_struct("SccmClientIntakeBundle", field_count)?; + state.serialize_field("artifacts", &self.artifacts)?; + if !self.capture_gaps.is_empty() { + state.serialize_field("captureGaps", &self.capture_gaps)?; + } + state.end() + } +} + +impl<'de> Deserialize<'de> for SccmClientIntakeBundle { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + const FIELDS: &[&str] = &["artifacts", "captureGaps"]; + deserializer.deserialize_struct( + "SccmClientIntakeBundle", + FIELDS, + SccmClientIntakeBundleVisitor, + ) + } +} + +#[derive(Deserialize)] +#[serde(field_identifier, rename_all = "camelCase")] +enum SccmClientIntakeBundleField { + Artifacts, + CaptureGaps, +} + +struct SccmClientIntakeBundleVisitor; + +impl<'de> Visitor<'de> for SccmClientIntakeBundleVisitor { + type Value = SccmClientIntakeBundle; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an SCCM client intake bundle") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut artifacts = None; + let mut capture_gaps = None; + let mut remaining = MAX_SCCM_CLIENT_INTAKE_ARTIFACTS; + + while let Some(field) = map.next_key()? { + match field { + SccmClientIntakeBundleField::Artifacts => { + if artifacts.is_some() { + return Err(A::Error::duplicate_field("artifacts")); + } + let decoded = map.next_value_seed(BoundedArtifactsSeed { limit: remaining })?; + remaining -= decoded.len(); + artifacts = Some(decoded); + } + SccmClientIntakeBundleField::CaptureGaps => { + if capture_gaps.is_some() { + return Err(A::Error::duplicate_field("captureGaps")); + } + let decoded = + map.next_value_seed(BoundedCaptureGapsSeed { limit: remaining })?; + remaining -= decoded.len(); + capture_gaps = Some(decoded); + } + } + } + + let bundle = SccmClientIntakeBundle { + artifacts: artifacts.ok_or_else(|| A::Error::missing_field("artifacts"))?, + capture_gaps: capture_gaps.unwrap_or_default(), + }; + validate_bundle(&bundle).map_err(A::Error::custom)?; + Ok(bundle) + } +} + +struct BoundedCaptureGapsSeed { + limit: usize, +} + +impl<'de> DeserializeSeed<'de> for BoundedCaptureGapsSeed { + type Value = Vec; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(BoundedCaptureGapVisitor { limit: self.limit }) + } +} + +struct BoundedCaptureGapVisitor { + limit: usize, +} + +impl<'de> Visitor<'de> for BoundedCaptureGapVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "at most {} SCCM client capture gaps", self.limit) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + if sequence.size_hint().is_some_and(|size| size > self.limit) { + return Err(A::Error::custom( + SccmClientIntakeError::ArtifactLimitExceeded, + )); + } + + let initial_capacity = sequence.size_hint().unwrap_or_default().min(self.limit); + let mut capture_gaps = Vec::with_capacity(initial_capacity); + while capture_gaps.len() < self.limit { + let Some(capture_gap) = sequence.next_element()? else { + return Ok(capture_gaps); + }; + capture_gaps.push(capture_gap); + } + + if sequence.next_element::()?.is_some() { + return Err(A::Error::custom( + SccmClientIntakeError::ArtifactLimitExceeded, + )); + } + + Ok(capture_gaps) + } +} + +struct BoundedArtifactsSeed { + limit: usize, +} + +impl<'de> DeserializeSeed<'de> for BoundedArtifactsSeed { + type Value = Vec; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(BoundedArtifactVisitor { limit: self.limit }) + } +} + +struct BoundedArtifactVisitor { + limit: usize, +} + +impl<'de> Visitor<'de> for BoundedArtifactVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "at most {} SCCM client artifacts", self.limit) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + if sequence.size_hint().is_some_and(|size| size > self.limit) { + return Err(A::Error::custom( + SccmClientIntakeError::ArtifactLimitExceeded, + )); + } + + let initial_capacity = sequence.size_hint().unwrap_or_default().min(self.limit); + let mut artifacts = Vec::with_capacity(initial_capacity); + while artifacts.len() < self.limit { + let Some(artifact) = sequence.next_element()? else { + return Ok(artifacts); + }; + artifacts.push(artifact); + } + + if sequence.next_element::()?.is_some() { + return Err(A::Error::custom( + SccmClientIntakeError::ArtifactLimitExceeded, + )); + } + + Ok(artifacts) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientIntakeFragment { + pub artifact_id: String, + pub basename: String, + pub rotation: SccmRotation, + pub coverage: SccmCoverageState, + pub path_fingerprint: Option, + pub rotation_lineage: Option, + pub relative_path: Option, + /// Boundary completeness projected independently from `coverage`. + /// In particular, `ParseFailed` plus `Some(true)` means the full fragment + /// was copied but could not be normalized as CCM evidence. + pub fragment_complete: Option, + pub configmgr_version: Option, + pub collected_at_utc: Option, + pub encoding: Option, + pub declared_byte_length: Option, + pub content_sha256: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientIntakeGroup { + pub logical_artifact_id: String, + pub coverage: SccmCoverageState, + pub fragments: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientIntakeCoverageGap { + pub logical_artifact_id: String, + pub artifact_id: Option, + pub role: SccmRole, + pub coverage: SccmCoverageState, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientUnsupportedArtifact { + pub artifact_id: String, + pub basename: String, + pub declared_coverage: SccmCoverageState, + pub classification: SccmCoverageState, + pub rotation: SccmRotation, + pub path_fingerprint: Option, + pub rotation_lineage: Option, + pub relative_path: Option, + pub fragment_complete: Option, + pub configmgr_version: Option, + pub collected_at_utc: Option, + pub encoding: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientIntakeAssessment { + pub schema_version: u32, + pub groups: Vec, + /// Every recognized physical source declaration in deterministic order. + /// Non-physical markers remain in their group and coverage-gap projections + /// but never masquerade as captured bundle artifacts. + pub physical_artifacts: Vec, + pub unsupported_artifacts: Vec, + /// Canonical coverage-only declarations. They preserve capture-limit + /// provenance without becoming physical artifacts or log fragments. + pub capture_gaps: Vec, + pub coverage_gaps: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmClientIntakeFragmentWire { + artifact_id: String, + basename: String, + rotation: SccmRotation, + coverage: SccmCoverageState, + path_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + rotation_lineage: Option, + relative_path: Option, + fragment_complete: Option, + configmgr_version: Option, + collected_at_utc: Option, + encoding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + declared_byte_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + content_sha256: Option, +} + +impl From for SccmClientIntakeFragment { + fn from(wire: SccmClientIntakeFragmentWire) -> Self { + Self { + artifact_id: wire.artifact_id, + basename: wire.basename, + rotation: wire.rotation, + coverage: wire.coverage, + path_fingerprint: wire.path_fingerprint, + rotation_lineage: wire.rotation_lineage, + relative_path: wire.relative_path, + fragment_complete: wire.fragment_complete, + configmgr_version: wire.configmgr_version, + collected_at_utc: wire.collected_at_utc, + encoding: wire.encoding, + declared_byte_length: wire.declared_byte_length, + content_sha256: wire.content_sha256, + } + } +} + +impl From<&SccmClientIntakeFragment> for SccmClientIntakeFragmentWire { + fn from(fragment: &SccmClientIntakeFragment) -> Self { + Self { + artifact_id: fragment.artifact_id.clone(), + basename: fragment.basename.clone(), + rotation: fragment.rotation.clone(), + coverage: fragment.coverage.clone(), + path_fingerprint: fragment.path_fingerprint.clone(), + rotation_lineage: fragment.rotation_lineage.clone(), + relative_path: fragment.relative_path.clone(), + fragment_complete: fragment.fragment_complete, + configmgr_version: fragment.configmgr_version.clone(), + collected_at_utc: fragment.collected_at_utc.clone(), + encoding: fragment.encoding.clone(), + declared_byte_length: fragment.declared_byte_length, + content_sha256: fragment.content_sha256.clone(), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmClientIntakeGroupWire { + logical_artifact_id: String, + coverage: SccmCoverageState, + fragments: Vec, +} + +impl From for SccmClientIntakeGroup { + fn from(wire: SccmClientIntakeGroupWire) -> Self { + Self { + logical_artifact_id: wire.logical_artifact_id, + coverage: wire.coverage, + fragments: wire.fragments.into_iter().map(Into::into).collect(), + } + } +} + +impl From<&SccmClientIntakeGroup> for SccmClientIntakeGroupWire { + fn from(group: &SccmClientIntakeGroup) -> Self { + Self { + logical_artifact_id: group.logical_artifact_id.clone(), + coverage: group.coverage.clone(), + fragments: group.fragments.iter().map(Into::into).collect(), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmClientIntakeCoverageGapWire { + logical_artifact_id: String, + #[serde(default)] + artifact_id: Option, + role: SccmRole, + coverage: SccmCoverageState, + reason: String, +} + +impl From for SccmClientIntakeCoverageGap { + fn from(wire: SccmClientIntakeCoverageGapWire) -> Self { + Self { + logical_artifact_id: wire.logical_artifact_id, + artifact_id: wire.artifact_id, + role: wire.role, + coverage: wire.coverage, + reason: wire.reason, + } + } +} + +impl From<&SccmClientIntakeCoverageGap> for SccmClientIntakeCoverageGapWire { + fn from(gap: &SccmClientIntakeCoverageGap) -> Self { + Self { + logical_artifact_id: gap.logical_artifact_id.clone(), + artifact_id: gap.artifact_id.clone(), + role: gap.role.clone(), + coverage: gap.coverage.clone(), + reason: gap.reason.clone(), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmClientUnsupportedArtifactWire { + artifact_id: String, + basename: String, + declared_coverage: SccmCoverageState, + classification: SccmCoverageState, + rotation: SccmRotation, + path_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + rotation_lineage: Option, + relative_path: Option, + fragment_complete: Option, + configmgr_version: Option, + collected_at_utc: Option, + encoding: Option, +} + +impl From for SccmClientUnsupportedArtifact { + fn from(wire: SccmClientUnsupportedArtifactWire) -> Self { + Self { + artifact_id: wire.artifact_id, + basename: wire.basename, + declared_coverage: wire.declared_coverage, + classification: wire.classification, + rotation: wire.rotation, + path_fingerprint: wire.path_fingerprint, + rotation_lineage: wire.rotation_lineage, + relative_path: wire.relative_path, + fragment_complete: wire.fragment_complete, + configmgr_version: wire.configmgr_version, + collected_at_utc: wire.collected_at_utc, + encoding: wire.encoding, + } + } +} + +impl From<&SccmClientUnsupportedArtifact> for SccmClientUnsupportedArtifactWire { + fn from(unsupported: &SccmClientUnsupportedArtifact) -> Self { + Self { + artifact_id: unsupported.artifact_id.clone(), + basename: unsupported.basename.clone(), + declared_coverage: unsupported.declared_coverage.clone(), + classification: unsupported.classification.clone(), + rotation: unsupported.rotation.clone(), + path_fingerprint: unsupported.path_fingerprint.clone(), + rotation_lineage: unsupported.rotation_lineage.clone(), + relative_path: unsupported.relative_path.clone(), + fragment_complete: unsupported.fragment_complete, + configmgr_version: unsupported.configmgr_version.clone(), + collected_at_utc: unsupported.collected_at_utc.clone(), + encoding: unsupported.encoding.clone(), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmClientIntakeAssessmentWire { + schema_version: u32, + groups: Vec, + physical_artifacts: Vec, + unsupported_artifacts: Vec, + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + deserialize_with = "deserialize_bounded_assessment_capture_gaps" + )] + capture_gaps: Vec, + coverage_gaps: Vec, +} + +fn deserialize_bounded_assessment_capture_gaps<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + // Each assessment capture gap reconstructs one canonical bundle + // declaration in `validate_assessment_projection`. Decode no more than + // that authoritative shared ceiling; the final projection validation + // accounts for physical, nonphysical, unsupported, and gap declarations + // together. + BoundedCaptureGapsSeed { + limit: MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, + } + .deserialize(deserializer) +} + +impl From<&SccmClientIntakeAssessment> for SccmClientIntakeAssessmentWire { + fn from(assessment: &SccmClientIntakeAssessment) -> Self { + Self { + schema_version: assessment.schema_version, + groups: assessment.groups.iter().map(Into::into).collect(), + physical_artifacts: assessment + .physical_artifacts + .iter() + .map(Into::into) + .collect(), + unsupported_artifacts: assessment + .unsupported_artifacts + .iter() + .map(Into::into) + .collect(), + capture_gaps: assessment.capture_gaps.clone(), + coverage_gaps: assessment.coverage_gaps.iter().map(Into::into).collect(), + } + } +} + +impl Serialize for SccmClientIntakeAssessment { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + validate_assessment_projection(self).map_err(S::Error::custom)?; + SccmClientIntakeAssessmentWire::from(self).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SccmClientIntakeAssessment { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = SccmClientIntakeAssessmentWire::deserialize(deserializer)?; + let assessment = Self { + schema_version: wire.schema_version, + groups: wire.groups.into_iter().map(Into::into).collect(), + physical_artifacts: wire + .physical_artifacts + .into_iter() + .map(Into::into) + .collect(), + unsupported_artifacts: wire + .unsupported_artifacts + .into_iter() + .map(Into::into) + .collect(), + capture_gaps: wire.capture_gaps, + coverage_gaps: wire.coverage_gaps.into_iter().map(Into::into).collect(), + }; + + validate_assessment_projection(&assessment).map_err(D::Error::custom)?; + Ok(assessment) + } +} + +impl SccmClientIntakeAssessment { + pub fn group(&self, logical_artifact_id: &str) -> Option<&SccmClientIntakeGroup> { + self.groups + .iter() + .find(|group| group.logical_artifact_id == logical_artifact_id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SccmClientIntakeError { + #[error("client intake artifact count exceeds the supported limit")] + ArtifactLimitExceeded, + #[error("client intake artifact identity is empty, unsafe, or too long")] + InvalidArtifactId, + #[error("client intake artifact basename is empty, unsafe, or too long")] + InvalidBasename, + #[error("client intake artifact rotation is malformed or unsafe")] + InvalidRotation, + #[error("client intake artifact collection timestamp is not RFC 3339")] + InvalidCollectedAt, + #[error("client intake artifact ConfigMgr version is unsafe or too long")] + InvalidConfigMgrVersion, + #[error("client intake artifact encoding is unsafe or too long")] + InvalidEncoding, + #[error("client intake accepts only artifacts explicitly classified as the client role")] + RoleMismatch, + #[error("client intake contains a duplicate artifact ID or source declaration")] + DuplicateArtifactId, + #[error("client intake contains an invalid path fingerprint")] + InvalidPathFingerprint, + #[error("client intake contains an invalid rotation lineage")] + InvalidRotationLineage, + #[error("client intake contains an invalid bundle-relative evidence path")] + InvalidRelativePath, + #[error("client intake contains a colliding path identity")] + CollidingPhysicalIdentity, + #[error("a physical capture state is missing its collision-safe path provenance")] + MissingPhysicalProvenance, + #[error("client intake fragment completeness must be explicitly declared")] + MissingFragmentCompleteness, + #[error("client intake fragment completeness contradicts its declared coverage state")] + InvalidFragmentCompleteness, + #[error("client intake content length and digest binding is malformed or not capture-local")] + InvalidContentBinding, + #[error("client intake capture gap is malformed, unsupported, or not coverage-only")] + InvalidCaptureGap, +} + +#[derive(Clone, Copy)] +struct ClientSourceGroupSpec { + logical_artifact_id: &'static str, + workflows: &'static [SccmClientWorkflow], + requiredness: SccmClientSourceRequiredness, +} + +const HEALTH: &[SccmClientWorkflow] = &[SccmClientWorkflow::Health]; +const POLICY: &[SccmClientWorkflow] = &[SccmClientWorkflow::Policy]; +const DEPLOYMENT: &[SccmClientWorkflow] = &[SccmClientWorkflow::Deployment]; +const TASK_SEQUENCE: &[SccmClientWorkflow] = &[SccmClientWorkflow::TaskSequence]; +const UPDATES: &[SccmClientWorkflow] = &[SccmClientWorkflow::Updates]; +const DEPLOYMENT_UPDATES: &[SccmClientWorkflow] = + &[SccmClientWorkflow::Deployment, SccmClientWorkflow::Updates]; +const INVENTORY: &[SccmClientWorkflow] = &[SccmClientWorkflow::Inventory]; +const COMPLIANCE: &[SccmClientWorkflow] = &[SccmClientWorkflow::Compliance]; +const METERING: &[SccmClientWorkflow] = &[SccmClientWorkflow::Metering]; +const HEALTH_DEPLOYMENT: &[SccmClientWorkflow] = + &[SccmClientWorkflow::Health, SccmClientWorkflow::Deployment]; + +const CLIENT_SOURCE_GROUPS: &[ClientSourceGroupSpec] = &[ + ClientSourceGroupSpec { + logical_artifact_id: "client-app-enforce", + workflows: DEPLOYMENT, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-app-intent", + workflows: DEPLOYMENT, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-ccmsetup", + workflows: HEALTH, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-compliance", + workflows: COMPLIANCE, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-content", + workflows: DEPLOYMENT_UPDATES, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-evaluation", + workflows: HEALTH, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-identity", + workflows: HEALTH, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-inventory", + workflows: INVENTORY, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-location", + workflows: HEALTH_DEPLOYMENT, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-location-services-shared", + workflows: UPDATES, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-maintenance-window", + workflows: UPDATES, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-metering", + workflows: METERING, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-policy-agent", + workflows: POLICY, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-task-sequence-smsts", + workflows: TASK_SEQUENCE, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-policy-state", + workflows: POLICY, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-reboot", + workflows: UPDATES, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-updates", + workflows: UPDATES, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-windows-update-supplemental", + workflows: UPDATES, + requiredness: SccmClientSourceRequiredness::Supplemental, + }, +]; + +pub fn declared_client_source_groups() -> Vec { + CLIENT_SOURCE_GROUPS + .iter() + .map(|group| SccmClientSourceGroupDefinition { + logical_artifact_id: group.logical_artifact_id.to_owned(), + accepted_basenames: declared_client_source_memberships() + .iter() + .filter(|source| { + source + .logical_artifact_ids + .contains(&group.logical_artifact_id) + }) + .map(|source| source.basename.to_owned()) + .collect(), + workflows: group.workflows.to_vec(), + requiredness: group.requiredness, + }) + .collect() +} + +pub fn assess_client_intake( + bundle: &SccmClientIntakeBundle, +) -> Result { + validate_bundle(bundle)?; + + let mut physical_artifacts = Vec::new(); + let mut unsupported_artifacts = Vec::new(); + let mut memberships: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut capture_gap_memberships: BTreeMap<&str, Vec> = + BTreeMap::new(); + + for source in &bundle.artifacts { + let matching_groups = + matching_groups(&source.artifact.display_name, &source.artifact.rotation); + if matching_groups.is_empty() { + unsupported_artifacts.push(SccmClientUnsupportedArtifact { + artifact_id: source.artifact.artifact_id.clone(), + basename: source.artifact.display_name.clone(), + declared_coverage: source.artifact.coverage.clone(), + classification: SccmCoverageState::Unsupported, + rotation: source.artifact.rotation.clone(), + path_fingerprint: source.path_fingerprint.clone(), + rotation_lineage: source.rotation_lineage.clone(), + relative_path: source.relative_path.clone(), + fragment_complete: source.fragment_complete, + configmgr_version: source.artifact.configmgr_version.clone(), + collected_at_utc: normalized_collected_at( + source.artifact.collected_at_utc.as_deref(), + ), + encoding: source.artifact.encoding.clone(), + }); + continue; + } + + let fragment = intake_fragment(source); + if is_physical_state(&fragment.coverage) { + physical_artifacts.push(fragment.clone()); + } + for group in matching_groups { + memberships + .entry(group.logical_artifact_id) + .or_default() + .push(fragment.clone()); + } + } + + for capture_gap in &bundle.capture_gaps { + for group in matching_groups(&capture_gap.basename, &capture_gap.rotation) { + capture_gap_memberships + .entry(group.logical_artifact_id) + .or_default() + .push(capture_gap.clone()); + } + } + + physical_artifacts.sort_by(compare_fragments); + unsupported_artifacts.sort_by(|left, right| { + left.artifact_id + .cmp(&right.artifact_id) + .then_with(|| left.basename.cmp(&right.basename)) + }); + let mut capture_gaps = bundle.capture_gaps.clone(); + capture_gaps.sort_by(compare_capture_gaps); + + let mut groups = Vec::with_capacity(CLIENT_SOURCE_GROUPS.len()); + let mut coverage_gaps = Vec::new(); + for definition in CLIENT_SOURCE_GROUPS { + let mut fragments = memberships + .remove(definition.logical_artifact_id) + .unwrap_or_default(); + fragments.sort_by(compare_fragments); + let mut group_capture_gaps = capture_gap_memberships + .remove(definition.logical_artifact_id) + .unwrap_or_default(); + group_capture_gaps.sort_by(compare_capture_gaps); + let coverage = group_coverage(&fragments, &group_capture_gaps); + if fragments.is_empty() && group_capture_gaps.is_empty() { + coverage_gaps.push(SccmClientIntakeCoverageGap { + logical_artifact_id: definition.logical_artifact_id.to_owned(), + artifact_id: None, + role: SccmRole::Client, + reason: coverage_reason(&coverage).to_owned(), + coverage: coverage.clone(), + }); + } else { + for fragment in &fragments { + if let Some(reason) = source_coverage_reason(fragment) { + coverage_gaps.push(SccmClientIntakeCoverageGap { + logical_artifact_id: definition.logical_artifact_id.to_owned(), + artifact_id: Some(fragment.artifact_id.clone()), + role: SccmRole::Client, + coverage: fragment.coverage.clone(), + reason, + }); + } + } + for capture_gap in &group_capture_gaps { + coverage_gaps.push(SccmClientIntakeCoverageGap { + logical_artifact_id: definition.logical_artifact_id.to_owned(), + artifact_id: Some(capture_gap.artifact_id.clone()), + role: SccmRole::Client, + coverage: capture_gap.coverage.clone(), + reason: capture_gap_coverage_reason(capture_gap), + }); + } + } + groups.push(SccmClientIntakeGroup { + logical_artifact_id: definition.logical_artifact_id.to_owned(), + coverage, + fragments, + }); + } + + Ok(SccmClientIntakeAssessment { + schema_version: SCCM_DIAGNOSTICS_SCHEMA_VERSION, + groups, + physical_artifacts, + unsupported_artifacts, + capture_gaps, + coverage_gaps, + }) +} + +fn validate_assessment_projection(assessment: &SccmClientIntakeAssessment) -> Result<(), String> { + let mut artifacts = assessment + .physical_artifacts + .iter() + .map(fragment_as_intake_artifact) + .collect::>(); + let mut nonphysical_fragments = BTreeMap::new(); + + for group in &assessment.groups { + for fragment in &group.fragments { + if is_physical_state(&fragment.coverage) { + continue; + } + + if let Some(existing) = + nonphysical_fragments.insert(fragment.artifact_id.clone(), fragment.clone()) + { + if existing != *fragment { + return Err( + "client intake assessment repeats one artifact ID with conflicting projections" + .to_owned(), + ); + } + } + } + } + + artifacts.extend( + nonphysical_fragments + .values() + .map(fragment_as_intake_artifact), + ); + artifacts.extend( + assessment + .unsupported_artifacts + .iter() + .map(unsupported_as_intake_artifact), + ); + + let canonical = assess_client_intake(&SccmClientIntakeBundle { + artifacts, + capture_gaps: assessment.capture_gaps.clone(), + }) + .map_err(|error| format!("invalid client intake assessment projection: {error}"))?; + if canonical != *assessment { + return Err( + "client intake assessment is not the canonical projection of its artifacts".to_owned(), + ); + } + + Ok(()) +} + +fn fragment_as_intake_artifact(fragment: &SccmClientIntakeFragment) -> SccmClientIntakeArtifact { + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: fragment.artifact_id.clone(), + display_name: fragment.basename.clone(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: fragment.configmgr_version.clone(), + collected_at_utc: fragment.collected_at_utc.clone(), + rotation: fragment.rotation.clone(), + coverage: fragment.coverage.clone(), + encoding: fragment.encoding.clone(), + }, + path_fingerprint: fragment.path_fingerprint.clone(), + rotation_lineage: fragment.rotation_lineage.clone(), + relative_path: fragment.relative_path.clone(), + fragment_complete: fragment.fragment_complete, + declared_byte_length: fragment.declared_byte_length, + content_sha256: fragment.content_sha256.clone(), + } +} + +fn unsupported_as_intake_artifact( + unsupported: &SccmClientUnsupportedArtifact, +) -> SccmClientIntakeArtifact { + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: unsupported.artifact_id.clone(), + display_name: unsupported.basename.clone(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: unsupported.configmgr_version.clone(), + collected_at_utc: unsupported.collected_at_utc.clone(), + rotation: unsupported.rotation.clone(), + coverage: unsupported.declared_coverage.clone(), + encoding: unsupported.encoding.clone(), + }, + path_fingerprint: unsupported.path_fingerprint.clone(), + rotation_lineage: unsupported.rotation_lineage.clone(), + relative_path: unsupported.relative_path.clone(), + fragment_complete: unsupported.fragment_complete, + declared_byte_length: None, + content_sha256: None, + } +} + +fn validate_capture_gap_shape( + capture_gap: &SccmClientIntakeCaptureGap, +) -> Result<(), SccmClientIntakeError> { + if !is_safe_artifact_id(&capture_gap.artifact_id) + || serde_json::to_value(&capture_gap.rotation).is_err() + || !is_safe_unknown_rotation(&capture_gap.rotation) + || !is_safe_basename(&capture_gap.basename, &capture_gap.rotation) + || !matches!( + capture_gap.coverage, + SccmCoverageState::Capped | SccmCoverageState::ParseFailed + ) + || !is_safe_path_identity(&capture_gap.path_fingerprint) + || !is_safe_rotation_lineage(&capture_gap.rotation_lineage) + || matching_groups(&capture_gap.basename, &capture_gap.rotation).is_empty() + { + return Err(SccmClientIntakeError::InvalidCaptureGap); + } + + Ok(()) +} + +fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientIntakeError> { + if bundle + .artifacts + .len() + .saturating_add(bundle.capture_gaps.len()) + > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS + { + return Err(SccmClientIntakeError::ArtifactLimitExceeded); + } + + let mut artifact_ids = BTreeSet::new(); + let mut path_fingerprint_bindings: BTreeMap, String)> = BTreeMap::new(); + let mut rotation_lineage_bindings = BTreeMap::new(); + let mut lineage_rotation_identities = BTreeSet::new(); + let mut relative_paths = BTreeSet::new(); + // Canonical source identity (casefolded basename plus rotation + // discriminator) for every declaration, split by declaration shape so + // the identity intersects across ALL declarations for a source: a + // marker can never contradict physical evidence, and a fingerprint-less + // marker can never double-declare a source any other declaration + // already claims. The sibling server intake instead makes the path + // fingerprint mandatory on every declaration; the client keeps + // optional marker fingerprints for the committed all-absent fixture + // bundles, so this intersection is the fail-closed equivalent here. + let mut physical_source_identities = BTreeSet::new(); + let mut pinned_marker_identities = BTreeSet::new(); + let mut unpinned_marker_identities = BTreeSet::new(); + + for source in &bundle.artifacts { + if source.artifact.role != SccmRole::Client { + return Err(SccmClientIntakeError::RoleMismatch); + } + if !is_safe_artifact_id(&source.artifact.artifact_id) { + return Err(SccmClientIntakeError::InvalidArtifactId); + } + if serde_json::to_value(&source.artifact.rotation).is_err() + || !is_safe_unknown_rotation(&source.artifact.rotation) + { + return Err(SccmClientIntakeError::InvalidRotation); + } + if !is_safe_basename(&source.artifact.display_name, &source.artifact.rotation) { + return Err(SccmClientIntakeError::InvalidBasename); + } + if source + .artifact + .collected_at_utc + .as_deref() + .is_some_and(|value| !is_safe_collected_at(value)) + { + return Err(SccmClientIntakeError::InvalidCollectedAt); + } + if source + .artifact + .configmgr_version + .as_deref() + .is_some_and(|value| !is_safe_configmgr_version(value)) + { + return Err(SccmClientIntakeError::InvalidConfigMgrVersion); + } + if source + .artifact + .encoding + .as_deref() + .is_some_and(|value| !is_supported_encoding(value)) + { + return Err(SccmClientIntakeError::InvalidEncoding); + } + if !artifact_ids.insert(source.artifact.artifact_id.to_ascii_lowercase()) { + return Err(SccmClientIntakeError::DuplicateArtifactId); + } + if source + .rotation_lineage + .as_deref() + .is_some_and(|lineage| !is_safe_rotation_lineage(lineage)) + { + return Err(SccmClientIntakeError::InvalidRotationLineage); + } + + let path_fingerprint = match source.path_fingerprint.as_deref() { + Some(fingerprint) if !is_safe_path_identity(fingerprint) => { + return Err(SccmClientIntakeError::InvalidPathFingerprint); + } + Some(fingerprint) => Some(fingerprint.to_ascii_lowercase()), + None => None, + }; + + let basename = + source_basename_identity(&source.artifact.display_name, &source.artifact.rotation); + if let Some(lineage) = source.rotation_lineage.as_deref() { + if let Some((bound_basename, bound_fingerprint)) = + rotation_lineage_bindings.get(lineage) + { + if bound_basename != &basename || bound_fingerprint != &path_fingerprint { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + } else { + rotation_lineage_bindings.insert( + lineage.to_owned(), + (basename.clone(), path_fingerprint.clone()), + ); + } + if !lineage_rotation_identities.insert(( + lineage.to_owned(), + rotation_identity(&source.artifact.rotation), + )) { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + } + + if let Some(fingerprint) = path_fingerprint { + let lineage = source + .rotation_lineage + .as_ref() + .map(|value| value.to_owned()); + if let Some((bound_lineage, bound_basename)) = + path_fingerprint_bindings.get(&fingerprint) + { + if lineage.is_none() || bound_lineage != &lineage || bound_basename != &basename { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + } else { + path_fingerprint_bindings + .insert(fingerprint.clone(), (lineage.clone(), basename.clone())); + } + } + if let Some(relative_path) = source.relative_path.as_deref() { + if !is_safe_relative_path( + relative_path, + &source.artifact.display_name, + &source.artifact.rotation, + ) { + return Err(SccmClientIntakeError::InvalidRelativePath); + } + if !relative_paths.insert(relative_path.to_ascii_lowercase()) { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + } + + let fragment_complete = source + .fragment_complete + .ok_or(SccmClientIntakeError::MissingFragmentCompleteness)?; + validate_content_binding(source, fragment_complete)?; + let source_identity = ( + source.artifact.display_name.to_ascii_lowercase(), + rotation_identity(&source.artifact.rotation), + ); + if is_physical_state(&source.artifact.coverage) { + if source.artifact.coverage == SccmCoverageState::Capped && fragment_complete { + return Err(SccmClientIntakeError::InvalidFragmentCompleteness); + } + source + .path_fingerprint + .as_deref() + .ok_or(SccmClientIntakeError::MissingPhysicalProvenance)?; + source + .relative_path + .as_deref() + .ok_or(SccmClientIntakeError::MissingPhysicalProvenance)?; + // A fingerprint-less marker claims every configured root for this + // basename and rotation. A pinned marker for another root remains + // a distinct source and is checked by the fingerprint identity. + if unpinned_marker_identities.contains(&source_identity) { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + physical_source_identities.insert(source_identity); + } else { + if source.relative_path.is_some() { + return Err(SccmClientIntakeError::InvalidRelativePath); + } + if fragment_complete { + return Err(SccmClientIntakeError::InvalidFragmentCompleteness); + } + if source.path_fingerprint.is_some() { + // Pinned markers and physical captures under other configured + // roots are distinct sources. Reusing a fingerprint without + // an explicit lineage, or reusing one lineage/rotation pair, + // already failed the identity checks above. + if unpinned_marker_identities.contains(&source_identity) { + return Err(SccmClientIntakeError::DuplicateArtifactId); + } + pinned_marker_identities.insert(source_identity); + } else { + // Distinct caller labels must not double-declare the same + // missing source, whether the sibling is pinned or not. + if physical_source_identities.contains(&source_identity) { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + if pinned_marker_identities.contains(&source_identity) + || unpinned_marker_identities.contains(&source_identity) + { + return Err(SccmClientIntakeError::DuplicateArtifactId); + } + unpinned_marker_identities.insert(source_identity); + } + } + } + + for capture_gap in &bundle.capture_gaps { + validate_capture_gap_shape(capture_gap)?; + if !artifact_ids.insert(capture_gap.artifact_id.to_ascii_lowercase()) { + return Err(SccmClientIntakeError::DuplicateArtifactId); + } + + let path_fingerprint = capture_gap.path_fingerprint.to_ascii_lowercase(); + let lineage = capture_gap.rotation_lineage.clone(); + let basename = source_basename_identity(&capture_gap.basename, &capture_gap.rotation); + if let Some((bound_basename, bound_fingerprint)) = rotation_lineage_bindings.get(&lineage) { + if bound_basename != &basename + || bound_fingerprint.as_deref() != Some(&path_fingerprint) + { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + } else { + rotation_lineage_bindings.insert( + lineage.clone(), + (basename.clone(), Some(path_fingerprint.clone())), + ); + } + if !lineage_rotation_identities + .insert((lineage.clone(), rotation_identity(&capture_gap.rotation))) + { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + if let Some((bound_lineage, bound_basename)) = + path_fingerprint_bindings.get(&path_fingerprint) + { + if bound_lineage.as_deref() != Some(lineage.as_str()) || bound_basename != &basename { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + } else { + path_fingerprint_bindings.insert(path_fingerprint, (Some(lineage), basename)); + } + + let source_identity = ( + capture_gap.basename.to_ascii_lowercase(), + rotation_identity(&capture_gap.rotation), + ); + if unpinned_marker_identities.contains(&source_identity) { + return Err(SccmClientIntakeError::DuplicateArtifactId); + } + pinned_marker_identities.insert(source_identity); + } + + Ok(()) +} + +fn matching_groups( + display_name: &str, + rotation: &SccmRotation, +) -> Vec<&'static ClientSourceGroupSpec> { + let Some(source) = catalogued_client_source(display_name, rotation) else { + return Vec::new(); + }; + + source + .logical_artifact_ids + .iter() + .filter_map(|logical_artifact_id| { + CLIENT_SOURCE_GROUPS + .iter() + .find(|group| group.logical_artifact_id == *logical_artifact_id) + }) + .collect() +} + +pub(super) fn source_matches_group( + display_name: &str, + rotation: &SccmRotation, + logical_artifact_id: &str, +) -> bool { + matching_groups(display_name, rotation) + .iter() + .any(|group| group.logical_artifact_id == logical_artifact_id) +} + +fn catalogued_client_source( + display_name: &str, + rotation: &SccmRotation, +) -> Option<&'static SccmClientSourceMembership> { + let classified = classify_artifact_name(display_name, SccmRole::Client); + if !classified.supported_for_diagnosis || &classified.rotation != rotation { + return None; + } + + let source = declared_client_source_memberships() + .iter() + .find(|source| source.basename.eq_ignore_ascii_case(&classified.basename))?; + expected_rotated_name(source.basename, rotation) + .is_some_and(|expected| expected == display_name) + .then_some(source) +} + +fn source_basename_identity(display_name: &str, rotation: &SccmRotation) -> String { + catalogued_client_source(display_name, rotation) + .map(|source| source.basename.to_ascii_lowercase()) + .unwrap_or_else(|| display_name.to_ascii_lowercase()) +} + +fn expected_rotated_name(basename: &str, rotation: &SccmRotation) -> Option { + match rotation { + SccmRotation::Current => Some(basename.to_owned()), + SccmRotation::LoUnderscore => basename + .strip_suffix(".log") + .map(|stem| format!("{stem}.lo_")), + SccmRotation::Numbered(number) if *number > 0 => Some(format!("{basename}.{number}")), + SccmRotation::Timestamped(timestamp) => Some(format!("{basename}.{timestamp}")), + SccmRotation::Numbered(_) | SccmRotation::Unknown(_) => None, + } +} + +fn intake_fragment(source: &SccmClientIntakeArtifact) -> SccmClientIntakeFragment { + SccmClientIntakeFragment { + artifact_id: source.artifact.artifact_id.clone(), + basename: source.artifact.display_name.clone(), + rotation: source.artifact.rotation.clone(), + coverage: source.artifact.coverage.clone(), + path_fingerprint: source.path_fingerprint.clone(), + rotation_lineage: source.rotation_lineage.clone(), + relative_path: source.relative_path.clone(), + fragment_complete: source.fragment_complete, + configmgr_version: source.artifact.configmgr_version.clone(), + collected_at_utc: normalized_collected_at(source.artifact.collected_at_utc.as_deref()), + encoding: source.artifact.encoding.clone(), + declared_byte_length: source.declared_byte_length, + content_sha256: source.content_sha256.clone(), + } +} + +fn validate_content_binding( + source: &SccmClientIntakeArtifact, + fragment_complete: bool, +) -> Result<(), SccmClientIntakeError> { + let has_binding = match ( + source.declared_byte_length, + source.content_sha256.as_deref(), + ) { + (None, None) => false, + (Some(_), Some(digest)) if is_sha256_digest(digest) => true, + _ => return Err(SccmClientIntakeError::InvalidContentBinding), + }; + + let is_recognized_task_sequence_fragment = source.artifact.coverage + == SccmCoverageState::Captured + && source_matches_group( + &source.artifact.display_name, + &source.artifact.rotation, + "client-task-sequence-smsts", + ); + if has_binding + && (source.artifact.coverage != SccmCoverageState::Captured + || !(fragment_complete || is_recognized_task_sequence_fragment) + || matching_groups(&source.artifact.display_name, &source.artifact.rotation).is_empty()) + { + return Err(SccmClientIntakeError::InvalidContentBinding); + } + + Ok(()) +} + +fn normalized_collected_at(value: Option<&str>) -> Option { + value.map(|value| { + DateTime::parse_from_rfc3339(value) + .expect("client intake validates collection timestamps before projection") + .with_timezone(&Utc) + .to_rfc3339_opts(SecondsFormat::AutoSi, true) + }) +} + +fn group_coverage( + fragments: &[SccmClientIntakeFragment], + capture_gaps: &[SccmClientIntakeCaptureGap], +) -> SccmCoverageState { + fragments + .iter() + .map(|fragment| fragment.coverage.clone()) + .chain( + capture_gaps + .iter() + .map(|capture_gap| capture_gap.coverage.clone()), + ) + .max_by_key(coverage_rank) + .unwrap_or(SccmCoverageState::Absent) +} + +fn coverage_rank(coverage: &SccmCoverageState) -> u8 { + match coverage { + SccmCoverageState::Captured => 0, + SccmCoverageState::Absent => 1, + SccmCoverageState::Unsupported => 2, + SccmCoverageState::Skipped => 3, + SccmCoverageState::Capped => 4, + SccmCoverageState::AccessDenied => 5, + SccmCoverageState::ParseFailed => 6, + } +} + +fn coverage_reason(coverage: &SccmCoverageState) -> &'static str { + match coverage { + SccmCoverageState::Absent => { + "No artifact for this bounded client source group was supplied." + } + SccmCoverageState::AccessDenied => { + "Access was denied for this bounded client source group." + } + SccmCoverageState::Capped => "The bounded client source group reached its capture limit.", + SccmCoverageState::Skipped => "The bounded client source group was intentionally skipped.", + SccmCoverageState::Unsupported => { + "The supplied client source group is unsupported by this contract." + } + SccmCoverageState::ParseFailed => { + "The supplied client source group could not be normalized as CCM evidence." + } + SccmCoverageState::Captured => "", + } +} + +/// Per-source gap wording for every noncaptured declaration and for a +/// captured fragment that ends on an incomplete logical-record boundary. +/// The safe artifact ID disambiguates identical basenames declared under +/// separate configured roots. +fn source_coverage_reason(fragment: &SccmClientIntakeFragment) -> Option { + match &fragment.coverage { + SccmCoverageState::Absent => Some(format!( + "No artifact for client source {} was supplied.", + fragment.basename + )), + SccmCoverageState::AccessDenied => Some(format!( + "Access was denied for client source {}.", + fragment.basename + )), + SccmCoverageState::Capped => Some(format!( + "Client source {} reached its capture limit.", + fragment.basename + )), + SccmCoverageState::Skipped => Some(format!( + "Client source {} was intentionally skipped.", + fragment.basename + )), + SccmCoverageState::Unsupported => Some(format!( + "Client source {} was declared unsupported.", + fragment.basename + )), + SccmCoverageState::ParseFailed => Some(format!( + "Client source {} could not be normalized as CCM evidence.", + fragment.basename + )), + SccmCoverageState::Captured if fragment.fragment_complete == Some(false) => Some(format!( + "Client source {} was captured with an incomplete logical-record boundary.", + fragment.basename + )), + SccmCoverageState::Captured => None, + } +} + +fn capture_gap_coverage_reason(capture_gap: &SccmClientIntakeCaptureGap) -> String { + match capture_gap.coverage { + SccmCoverageState::Capped => format!( + "Client source rotation {} was omitted because its capture limit was reached.", + capture_gap.basename + ), + SccmCoverageState::ParseFailed => format!( + "Client source rotation {} was omitted because capture could not be completed.", + capture_gap.basename + ), + _ => unreachable!("capture gaps are validated as Capped or ParseFailed"), + } +} + +/// Stable rotation discriminator for the canonical source identity shared +/// by every declaration, physical or marker, so collisions intersect across +/// all declaration shapes for a source. +fn rotation_identity(rotation: &SccmRotation) -> String { + match rotation { + SccmRotation::Current => "current".to_owned(), + SccmRotation::LoUnderscore => "lo".to_owned(), + SccmRotation::Numbered(number) => format!("numbered-{number}"), + SccmRotation::Timestamped(timestamp) => format!("timestamped-{timestamp}"), + SccmRotation::Unknown(unknown) => format!( + "unknown:{}:{}", + unknown.kind, + unknown + .value + .as_ref() + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + ), + } +} + +fn compare_fragments( + left: &SccmClientIntakeFragment, + right: &SccmClientIntakeFragment, +) -> Ordering { + left.path_fingerprint + .as_deref() + .unwrap_or_default() + .cmp(right.path_fingerprint.as_deref().unwrap_or_default()) + .then_with(|| { + left.rotation_lineage + .as_deref() + .unwrap_or_default() + .cmp(right.rotation_lineage.as_deref().unwrap_or_default()) + }) + .then_with(|| compare_rotation(&left.rotation, &right.rotation)) + .then_with(|| left.basename.cmp(&right.basename)) + .then_with(|| left.artifact_id.cmp(&right.artifact_id)) +} + +fn compare_capture_gaps( + left: &SccmClientIntakeCaptureGap, + right: &SccmClientIntakeCaptureGap, +) -> Ordering { + left.path_fingerprint + .cmp(&right.path_fingerprint) + .then_with(|| left.rotation_lineage.cmp(&right.rotation_lineage)) + .then_with(|| compare_rotation(&left.rotation, &right.rotation)) + .then_with(|| left.basename.cmp(&right.basename)) + .then_with(|| left.artifact_id.cmp(&right.artifact_id)) +} + +fn compare_rotation(left: &SccmRotation, right: &SccmRotation) -> Ordering { + rotation_rank(left) + .cmp(&rotation_rank(right)) + .then_with(|| match (left, right) { + (SccmRotation::Numbered(left), SccmRotation::Numbered(right)) => left.cmp(right), + (SccmRotation::Timestamped(left), SccmRotation::Timestamped(right)) => left.cmp(right), + (SccmRotation::Unknown(_), SccmRotation::Unknown(_)) => { + rotation_identity(left).cmp(&rotation_identity(right)) + } + _ => Ordering::Equal, + }) +} + +fn rotation_rank(rotation: &SccmRotation) -> u8 { + match rotation { + SccmRotation::Current => 0, + SccmRotation::LoUnderscore => 1, + SccmRotation::Numbered(_) => 2, + SccmRotation::Timestamped(_) => 3, + SccmRotation::Unknown(_) => 4, + } +} + +fn is_physical_state(coverage: &SccmCoverageState) -> bool { + matches!( + coverage, + SccmCoverageState::Captured | SccmCoverageState::Capped | SccmCoverageState::ParseFailed + ) +} + +pub(super) fn is_safe_artifact_id(value: &str) -> bool { + if value.is_empty() || value.chars().count() > MAX_ARTIFACT_ID_CHARS { + return false; + } + + if let Some(payload) = value.strip_prefix("fixture-") { + return is_safe_synthetic_fingerprint(payload); + } + + value + .strip_prefix(NATIVE_ARTIFACT_ID_PREFIX_V1) + .is_some_and(is_sha256_digest) +} + +fn is_safe_basename(value: &str, rotation: &SccmRotation) -> bool { + let structurally_safe = !value.is_empty() + && value == value.trim() + && value.chars().count() <= MAX_BASENAME_CHARS + && value.is_ascii() + && !value.contains(['/', '\\', ':', '@']) + && !value.chars().any(char::is_control); + if !structurally_safe { + return false; + } + + if !matching_groups(value, rotation).is_empty() { + return true; + } + + if matches!(rotation, SccmRotation::Unknown(_)) && is_canonical_client_basename(value) { + return true; + } + + REVIEWED_UNSUPPORTED_SYNTHETIC_BASENAMES.contains(&value) + || is_opaque_unsupported_basename(value) +} + +fn is_canonical_client_basename(value: &str) -> bool { + declared_client_source_memberships() + .iter() + .any(|source| source.basename == value) +} + +fn is_opaque_unsupported_basename(value: &str) -> bool { + value + .strip_prefix(OPAQUE_UNSUPPORTED_BASENAME_PREFIX_V1) + .and_then(|value| value.strip_suffix(".log")) + .is_some_and(is_sha256_digest) +} + +fn is_safe_collected_at(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_COLLECTED_AT_CHARS + && value.is_ascii() + && DateTime::parse_from_rfc3339(value).is_ok() +} + +fn is_safe_unknown_rotation(rotation: &SccmRotation) -> bool { + let SccmRotation::Unknown(unknown) = rotation else { + return true; + }; + + unknown.kind == OPAQUE_ROTATION_KIND_V1 + && unknown.value.as_ref().is_some_and(|value| { + value + .as_str() + .is_some_and(|value| value.strip_prefix("sha256:").is_some_and(is_sha256_digest)) + }) +} + +fn is_safe_configmgr_version(value: &str) -> bool { + if matches!(value, "5.00.TEST.0000" | "5.00.UNKNOWN.0000") { + return true; + } + + let mut components = value.split('.'); + matches!(components.next(), Some("5")) + && matches!(components.next(), Some("00")) + && components.next().is_some_and(is_four_ascii_digits) + && components.next().is_some_and(is_four_ascii_digits) + && components.next().is_none() +} + +fn is_four_ascii_digits(value: &str) -> bool { + value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +pub(super) fn is_supported_encoding(value: &str) -> bool { + matches!(value, "utf-8" | "utf-16le" | "utf-16be" | "windows-1252") +} + +fn is_safe_path_identity(value: &str) -> bool { + if value.is_empty() || value.chars().count() > MAX_PATH_IDENTITY_CHARS { + return false; + } + + if let Some(payload) = value.strip_prefix("synthetic-") { + return is_safe_synthetic_fingerprint(payload); + } + + match value.split_once(':') { + Some(("synthetic", payload)) => is_safe_synthetic_fingerprint(payload), + Some(("sha256", digest)) => is_sha256_digest(digest), + _ => false, + } +} + +fn is_safe_rotation_lineage(value: &str) -> bool { + value + .strip_prefix("synthetic:") + .is_some_and(is_safe_synthetic_fingerprint) + || value + .strip_prefix("cmtraceopen.lineage.sha256.v1:") + .is_some_and(is_sha256_digest) +} + +fn is_safe_synthetic_fingerprint(payload: &str) -> bool { + let tokens = payload.split([':', '-']).collect::>(); + tokens.len() <= MAX_SYNTHETIC_FINGERPRINT_TOKENS + && tokens.iter().enumerate().all(|(index, token)| { + !token.is_empty() + && (SYNTHETIC_FINGERPRINT_TOKENS.contains(token) + || (token.len() <= 2 + && token.bytes().all(|byte| byte.is_ascii_digit()) + && index > 0 + && index + 1 == tokens.len() + && tokens[index - 1] == "numbered")) + }) +} + +fn is_safe_relative_path(value: &str, display_name: &str, rotation: &SccmRotation) -> bool { + if value.chars().count() > MAX_PATH_IDENTITY_CHARS { + return false; + } + + let segments = value.split('/').collect::>(); + let body = if segments.starts_with(&["evidence", "sccm", "client"]) { + &segments[3..] + } else if segments.starts_with(&["evidence"]) { + &segments[1..] + } else { + return false; + }; + + if body.first() == Some(&"client-task-sequence-smsts") { + return is_safe_task_sequence_relative_path(body, display_name, rotation); + } + + let (group, rotation_segment, basename, root_is_safe) = match body { + [group, basename] => (*group, None, *basename, true), + [group, rotation, basename] => (*group, Some(*rotation), *basename, true), + [group, root, rotation, basename] => ( + *group, + Some(*rotation), + *basename, + is_safe_root_path_segment(root), + ), + _ => return false, + }; + + root_is_safe + && is_safe_client_bundle_group(group) + && is_expected_client_bundle_group(group, display_name, rotation) + && basename == display_name + && is_safe_path_segment(basename) + && is_expected_rotation_path_segment(rotation_segment, rotation) +} + +fn is_safe_task_sequence_relative_path( + body: &[&str], + display_name: &str, + rotation: &SccmRotation, +) -> bool { + let (path_class, root, rotation_segment, basename) = match body { + [_, path_class, basename] => (*path_class, None, None, *basename), + [_, path_class, rotation_segment, basename] => { + (*path_class, None, Some(*rotation_segment), *basename) + } + [_, path_class, root, rotation_segment, basename] => { + (*path_class, Some(*root), Some(*rotation_segment), *basename) + } + _ => return false, + }; + + task_sequence_path_class(path_class).is_some() + && root.is_none_or(is_safe_root_path_segment) + && is_expected_client_bundle_group("client-task-sequence-smsts", display_name, rotation) + && basename == display_name + && is_safe_path_segment(basename) + && is_expected_rotation_path_segment(rotation_segment, rotation) +} + +pub(super) fn task_sequence_path_class_for_relative_path( + value: &str, +) -> Option { + let segments = value.split('/').collect::>(); + let body = if segments.starts_with(&["evidence", "sccm", "client"]) { + &segments[3..] + } else if segments.starts_with(&["evidence"]) { + &segments[1..] + } else { + return None; + }; + match body { + ["client-task-sequence-smsts", path_class, ..] => task_sequence_path_class(path_class), + _ => None, + } +} + +fn task_sequence_path_class(value: &str) -> Option { + match value { + "winpe" => Some(SccmTaskSequencePathClass::WinPe), + "setup" => Some(SccmTaskSequencePathClass::Setup), + "full-os" => Some(SccmTaskSequencePathClass::FullOs), + "client" => Some(SccmTaskSequencePathClass::Client), + "unknown" => Some(SccmTaskSequencePathClass::Unknown), + _ => None, + } +} + +fn is_expected_client_bundle_group( + group: &str, + display_name: &str, + rotation: &SccmRotation, +) -> bool { + let matching_groups = matching_groups(display_name, rotation); + match matching_groups.as_slice() { + [] => group == "unknown", + [matching_group] => group == matching_group.logical_artifact_id, + _ => { + group == "client-location-services-shared" + && catalogued_client_source(display_name, rotation) + .is_some_and(|source| source.basename == "LocationServices.log") + } + } +} + +fn is_expected_rotation_path_segment(segment: Option<&str>, rotation: &SccmRotation) -> bool { + match (segment, rotation) { + (None, SccmRotation::Current | SccmRotation::Unknown(_)) => true, + (Some("current"), SccmRotation::Current) => true, + (Some("lo"), SccmRotation::LoUnderscore) => true, + (Some(segment), SccmRotation::Numbered(number)) => segment == format!("numbered-{number}"), + (Some(segment), SccmRotation::Timestamped(timestamp)) => { + segment == format!("timestamped-{timestamp}") + } + _ => false, + } +} + +fn is_safe_path_segment(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value.chars().count() <= MAX_BASENAME_CHARS + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-._".contains(character)) +} + +fn is_safe_client_bundle_group(value: &str) -> bool { + value == "unknown" + || value == "client-location-services-shared" + || CLIENT_SOURCE_GROUPS + .iter() + .any(|group| group.logical_artifact_id == value) +} + +fn is_safe_root_path_segment(value: &str) -> bool { + value.strip_prefix("root-").is_some_and(|root| { + // `root-a` and `root-b` are committed synthetic collision fixtures. + // Native adapters use an opaque lowercase hexadecimal handle. + matches!(root, "a" | "b") || is_lowercase_hex_handle(root) + }) +} + +/// Opaque root handle emitted by a native adapter, in either accepted width. +fn is_lowercase_hex_handle(value: &str) -> bool { + matches!(value.len(), 16 | 64) && is_lowercase_hex(value) +} + +/// A SHA-256 digest is exactly 64 lowercase hexadecimal characters. Owning +/// that width here keeps every digest caller from restating it, so a new +/// caller cannot silently admit a shorter handle. +fn is_sha256_digest(value: &str) -> bool { + value.len() == 64 && is_lowercase_hex(value) +} + +fn is_lowercase_hex(value: &str) -> bool { + value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/inventory.rs b/crates/cmtraceopen-parser/src/sccm/client/inventory.rs new file mode 100644 index 000000000..d8361ae68 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/inventory.rs @@ -0,0 +1,904 @@ +use std::{borrow::Cow, collections::BTreeMap}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::models::log_entry::Severity; + +use super::super::{ + normalize_key, SccmCorrelationKey, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, + SccmEvidenceRef, SccmFindingClass, SccmKeyConfidence, SccmRole, SccmRotation, + SccmTimeOrderingState, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, +}; +use super::admission::SccmClientAdmittedSourceArtifact; +use super::{SccmClientAdmittedEvidence, SccmClientEvidenceAdmissionError}; + +pub const SCCM_CLIENT_EXTENDED_ANALYSIS_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientExtendedWorkflow { + Inventory, + Compliance, + Metering, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientExtendedPhase { + Collect, + Provider, + Serialize, + Queue, + Evaluate, + Remediate, + Aggregate, + Report, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientExtendedState { + InProgress, + Succeeded, + Failed, + Recovered, + Contradictory, + EvaluatedCompliant, + EvaluatedNonCompliant, + Remediated, + BlockedOrDeferred, + InsufficientEvidence, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientExtendedTransaction { + pub transaction_id: String, + pub workflow: SccmClientExtendedWorkflow, + pub profile_id: String, + pub phase: SccmClientExtendedPhase, + pub source_basename: String, + pub state: SccmClientExtendedState, + pub last_successful_phase: Option, + pub keys: Vec, + pub evidence: Vec, + pub coverage_gap_artifact_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientExtendedSourceCitation { + pub artifact_id: String, + pub source_basename: String, + pub rotation: SccmRotation, + pub coverage: SccmCoverageState, + pub fragment_complete: bool, + pub physical: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientExtendedCoverage { + pub workflow: SccmClientExtendedWorkflow, + pub logical_artifact_id: String, + pub source: SccmClientExtendedSourceCitation, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientExtendedObservation { + pub workflow: SccmClientExtendedWorkflow, + pub reason: String, + pub sources: Vec, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientExtendedArtifactRequest { + pub logical_artifact_id: String, + pub source_basename: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientExtendedFinding { + pub finding_id: String, + pub subject_id: String, + pub workflow: SccmClientExtendedWorkflow, + pub role: SccmRole, + pub class: SccmFindingClass, + pub severity: Severity, + pub state: SccmClientExtendedState, + pub phase: SccmClientExtendedPhase, + pub confidence: SccmKeyConfidence, + pub keys: Vec, + pub next_artifact: Option, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientExtendedAnalysis { + pub schema_version: u32, + pub transactions: Vec, + pub coverage: Vec, + pub source_local_observations: Vec, + pub findings: Vec, + pub prohibited_claims: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct TransactionKey { + workflow: SccmClientExtendedWorkflow, + tuple: String, +} + +#[derive(Debug, Clone)] +struct Fact { + workflow: SccmClientExtendedWorkflow, + phase: SccmClientExtendedPhase, + disposition: Disposition, + terminal: bool, + evidence: SccmEvidence, + keys: Vec, + tuple: String, + profile_id: String, + result_type_evaluation: bool, + disposition_compliant: bool, + source_basename: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Disposition { + Succeeded, + Failed, + NonCompliant, + Deferred, + Other, +} + +/// Analyze inventory, compliance, and metering only through sealed client +/// evidence. The reducer never accepts caller-assembled artifacts or records. +pub fn analyze_client_extended( + admitted: &SccmClientAdmittedEvidence, +) -> Result { + let coverage = extended_coverage(admitted)?; + let mut observations = Vec::new(); + let mut facts = BTreeMap::>::new(); + + for (artifact_id, source) in admitted.source_artifacts()? { + let Some(workflow) = workflow_for_basename(&source.basename) else { + continue; + }; + if source.coverage == SccmCoverageState::Captured && source.fragment_complete == Some(true) + { + continue; + } + observations.push(SccmClientExtendedObservation { + workflow, + reason: if source.coverage == SccmCoverageState::Captured { + "An incomplete rotation fragment remains source-local coverage and cannot establish a workflow outcome." + } else { + "An unavailable or malformed source artifact remains coverage and cannot establish a workflow outcome." + } + .to_owned(), + sources: vec![source_citation(artifact_id, source)], + evidence: Vec::new(), + }); + } + + for evidence in admitted.evidence()? { + let Some(context) = evidence_context(admitted, evidence)? else { + continue; + }; + let profile_id = profile_id(context.workflow); + let Some(fact) = parse_fact(&context, profile_id, evidence, &mut observations) else { + continue; + }; + facts + .entry(TransactionKey { + workflow: fact.workflow, + tuple: fact.tuple.clone(), + }) + .or_default() + .push(fact); + } + + observations.sort_by(|left, right| { + left.workflow + .cmp(&right.workflow) + .then_with(|| { + left.sources + .iter() + .map(|source| source.artifact_id.as_str()) + .cmp( + right + .sources + .iter() + .map(|source| source.artifact_id.as_str()), + ) + }) + .then_with(|| { + left.evidence + .first() + .map(|reference| { + ( + reference.artifact_id.as_str(), + reference.line_start, + reference.line_end, + reference.entry_id.as_str(), + ) + }) + .cmp(&right.evidence.first().map(|reference| { + ( + reference.artifact_id.as_str(), + reference.line_start, + reference.line_end, + reference.entry_id.as_str(), + ) + })) + }) + .then_with(|| left.reason.cmp(&right.reason)) + }); + + let mut transactions = facts + .into_values() + .map(|mut group| reduce_group(&mut group)) + .collect::>(); + for transaction in &mut transactions { + transaction.coverage_gap_artifact_ids = coverage + .iter() + .filter(|gap| { + transaction + .evidence + .iter() + .any(|reference| reference.artifact_id == gap.source.artifact_id) + && !source_is_complete(&gap.source) + }) + .map(|gap| gap.source.artifact_id.clone()) + .collect(); + if !transaction.coverage_gap_artifact_ids.is_empty() { + transaction.state = SccmClientExtendedState::InsufficientEvidence; + } + } + let findings = transactions + .iter() + .filter_map(finding_for) + .collect::>(); + + Ok(SccmClientExtendedAnalysis { + schema_version: SCCM_CLIENT_EXTENDED_ANALYSIS_SCHEMA_VERSION, + transactions, + coverage, + source_local_observations: observations, + findings, + prohibited_claims: vec![ + "server root cause".to_owned(), + "time-only cross-artifact causality".to_owned(), + "native Windows acceptance".to_owned(), + ], + }) +} + +#[derive(Debug, Clone)] +struct ArtifactContext { + workflow: SccmClientExtendedWorkflow, + source_basename: &'static str, + source: SccmClientExtendedSourceCitation, +} + +fn extended_coverage( + admitted: &SccmClientAdmittedEvidence, +) -> Result, SccmClientEvidenceAdmissionError> { + let mut coverage = Vec::new(); + for (artifact_id, source) in admitted.source_artifacts()? { + let Some(workflow) = workflow_for_basename(&source.basename) else { + continue; + }; + let citation = source_citation(artifact_id, source); + coverage.push(SccmClientExtendedCoverage { + workflow, + logical_artifact_id: logical_artifact_for_basename(&source.basename).to_owned(), + reason: if source_is_complete(&citation) { + "This exact physical source was captured completely; transaction claims still require exact record evidence." + } else { + "This exact source was not captured completely; its coverage state cannot become a workflow outcome." + } + .to_owned(), + source: citation, + }); + } + Ok(coverage) +} + +fn source_citation( + artifact_id: &str, + source: &SccmClientAdmittedSourceArtifact, +) -> SccmClientExtendedSourceCitation { + SccmClientExtendedSourceCitation { + artifact_id: artifact_id.to_owned(), + source_basename: source.basename.clone(), + rotation: source.rotation.clone(), + coverage: source.coverage.clone(), + fragment_complete: source.fragment_complete == Some(true), + physical: source.physical, + } +} + +fn source_is_complete(source: &SccmClientExtendedSourceCitation) -> bool { + source.coverage == SccmCoverageState::Captured && source.fragment_complete && source.physical +} + +fn evidence_context( + admitted: &SccmClientAdmittedEvidence, + evidence: &SccmEvidence, +) -> Result, SccmClientEvidenceAdmissionError> { + let Some(component) = evidence.component.as_deref() else { + return Ok(None); + }; + let (workflow, source_basename) = match component.to_ascii_lowercase().as_str() { + "inventoryagent" => (SccmClientExtendedWorkflow::Inventory, "InventoryAgent.log"), + "inventoryprovider" => ( + SccmClientExtendedWorkflow::Inventory, + "InventoryProvider.log", + ), + "inventoryagentprovider" => ( + SccmClientExtendedWorkflow::Inventory, + "InventoryAgentProvider.log", + ), + "ciagent" => (SccmClientExtendedWorkflow::Compliance, "CIAgent.log"), + "citaskmgr" => (SccmClientExtendedWorkflow::Compliance, "CITaskMgr.log"), + "dcmagent" => (SccmClientExtendedWorkflow::Compliance, "DCMAgent.log"), + "dcmreporting" => (SccmClientExtendedWorkflow::Compliance, "DCMReporting.log"), + "statemessage" => (SccmClientExtendedWorkflow::Compliance, "StateMessage.log"), + "swmtrreportgen" => (SccmClientExtendedWorkflow::Metering, "SWMTRReportGen.log"), + _ => return Ok(None), + }; + let Some(source) = admitted + .source_artifacts()? + .get(&evidence.reference.artifact_id) + else { + return Ok(None); + }; + if source.basename != source_basename { + return Ok(None); + } + + Ok(Some(ArtifactContext { + workflow, + source_basename, + source: source_citation(&evidence.reference.artifact_id, source), + })) +} + +fn workflow_for_basename(basename: &str) -> Option { + match canonical_family_basename(basename).as_ref() { + "InventoryAgent.log" | "InventoryProvider.log" | "InventoryAgentProvider.log" => { + Some(SccmClientExtendedWorkflow::Inventory) + } + "CIAgent.log" | "CITaskMgr.log" | "DCMAgent.log" | "DCMReporting.log" + | "StateMessage.log" => Some(SccmClientExtendedWorkflow::Compliance), + "SWMTRReportGen.log" => Some(SccmClientExtendedWorkflow::Metering), + _ => None, + } +} + +fn parse_fact( + context: &ArtifactContext, + profile_id: &str, + evidence: &SccmEvidence, + observations: &mut Vec, +) -> Option { + let fields = match parse_unique_fields(&evidence.message) { + Ok(fields) => fields, + Err(()) => { + observe( + observations, + context, + evidence, + "The record repeats a field label, so its semantics are ambiguous.", + ); + return None; + } + }; + let phase = fields + .get("phase") + .and_then(|value| parse_phase(context.workflow, value)); + let disposition = fields + .get("disposition") + .map_or(Disposition::Other, |value| parse_disposition(value)); + let terminal = fields + .get("terminal") + .is_some_and(|value| value.eq_ignore_ascii_case("true")); + let Some(phase) = phase else { + observe( + observations, + context, + evidence, + "The record has no admitted phase for this workflow.", + ); + return None; + }; + if !source_allows_phase(context.source_basename, phase) { + observe( + observations, + context, + evidence, + "The source family cannot establish this phase.", + ); + return None; + } + if fields + .get("family") + .is_some_and(|value| !value.eq_ignore_ascii_case(workflow_name(context.workflow))) + { + observe( + observations, + context, + evidence, + "The record explicitly names a different workflow family.", + ); + return None; + } + + let required = required_fields(context.workflow); + let Some(values) = required + .iter() + .map(|label| fields.get(&label.to_ascii_lowercase()).cloned()) + .collect::>>() + else { + observe( + observations, + context, + evidence, + "The record lacks the complete exact workflow key tuple.", + ); + return None; + }; + let Some(keys) = make_keys(context.workflow, &values, profile_id, evidence) else { + observe( + observations, + context, + evidence, + "The workflow key tuple contains an invalid or unbounded value.", + ); + return None; + }; + if context.workflow == SccmClientExtendedWorkflow::Compliance + && disposition == Disposition::NonCompliant + && !fields + .get("resulttype") + .is_some_and(|value| value.eq_ignore_ascii_case("Evaluation")) + { + observe( + observations, + context, + evidence, + "A noncompliant result is promotable only from an explicit evaluation record.", + ); + return None; + } + let tuple = values.join("|"); + + Some(Fact { + workflow: context.workflow, + phase, + disposition, + terminal, + evidence: evidence.clone(), + keys, + tuple, + profile_id: profile_id.to_owned(), + result_type_evaluation: fields + .get("resulttype") + .is_some_and(|value| value.eq_ignore_ascii_case("Evaluation")), + disposition_compliant: fields + .get("disposition") + .is_some_and(|value| value.eq_ignore_ascii_case("Compliant")), + source_basename: context.source_basename.to_owned(), + }) +} + +fn required_fields(workflow: SccmClientExtendedWorkflow) -> &'static [&'static str] { + match workflow { + SccmClientExtendedWorkflow::Inventory => { + &["InventoryCycleId", "ResourceHandle", "ReportId"] + } + SccmClientExtendedWorkflow::Compliance => { + &["CiId", "BaselineId", "StateId", "ResourceHandle"] + } + SccmClientExtendedWorkflow::Metering => { + &["MeteringCycleId", "RuleId", "ReportId", "ResourceHandle"] + } + } +} + +fn make_keys( + workflow: SccmClientExtendedWorkflow, + values: &[String], + profile_id: &str, + evidence: &SccmEvidence, +) -> Option> { + let kinds = match workflow { + SccmClientExtendedWorkflow::Inventory => vec![ + SccmCorrelationKeyKind::InventoryCycleId, + SccmCorrelationKeyKind::ResourceHandle, + SccmCorrelationKeyKind::ReportId, + ], + SccmClientExtendedWorkflow::Compliance => vec![ + SccmCorrelationKeyKind::ComplianceCiId, + SccmCorrelationKeyKind::BaselineId, + SccmCorrelationKeyKind::ComplianceStateId, + SccmCorrelationKeyKind::ResourceHandle, + ], + SccmClientExtendedWorkflow::Metering => vec![ + SccmCorrelationKeyKind::MeteringCycleId, + SccmCorrelationKeyKind::RuleId, + SccmCorrelationKeyKind::ReportId, + SccmCorrelationKeyKind::ResourceHandle, + ], + }; + values + .iter() + .zip(kinds) + .map(|(value, kind)| { + let mut key = normalize_key(kind, value); + (key.confidence == super::super::SccmKeyConfidence::Exact).then(|| { + key.confidence = SccmKeyConfidence::Low; + key.extraction_profile_id = Some(profile_id.to_owned()); + key.evidence = Some(evidence.reference.clone()); + key + }) + }) + .collect() +} + +fn reduce_group(group: &mut [Fact]) -> SccmClientExtendedTransaction { + group.sort_by(|left, right| { + ( + left.evidence.timestamp.utc_millis, + &left.evidence.evidence_id, + ) + .cmp(&( + right.evidence.timestamp.utc_millis, + &right.evidence.evidence_id, + )) + }); + let first = &group[0]; + let terminal = group + .iter() + .filter(|fact| fact.terminal) + .collect::>(); + let successes = terminal + .iter() + .filter(|fact| fact.disposition == Disposition::Succeeded) + .copied() + .collect::>(); + let failures = terminal + .iter() + .filter(|fact| fact.disposition == Disposition::Failed) + .copied() + .collect::>(); + let noncompliant = terminal + .iter() + .filter(|fact| fact.disposition == Disposition::NonCompliant) + .copied() + .collect::>(); + let deferred = group + .iter() + .filter(|fact| fact.disposition == Disposition::Deferred) + .collect::>(); + let compliant = terminal + .iter() + .any(|fact| fact.disposition_compliant && fact.result_type_evaluation); + let remediated = first.workflow == SccmClientExtendedWorkflow::Compliance + && group.iter().any(|fact| { + fact.phase == SccmClientExtendedPhase::Remediate + && fact.disposition == Disposition::Succeeded + }) + && !successes.is_empty(); + + let state = if !failures.is_empty() && !successes.is_empty() { + if ordered_recovery(&failures, &successes) { + SccmClientExtendedState::Recovered + } else { + SccmClientExtendedState::Contradictory + } + } else if !failures.is_empty() { + SccmClientExtendedState::Failed + } else if !noncompliant.is_empty() && compliant { + SccmClientExtendedState::Contradictory + } else if !noncompliant.is_empty() { + SccmClientExtendedState::EvaluatedNonCompliant + } else if !deferred.is_empty() { + SccmClientExtendedState::BlockedOrDeferred + } else if remediated { + SccmClientExtendedState::Remediated + } else if compliant { + SccmClientExtendedState::EvaluatedCompliant + } else if !successes.is_empty() { + SccmClientExtendedState::Succeeded + } else { + SccmClientExtendedState::InProgress + }; + + let last_successful_phase = group + .iter() + .filter(|fact| { + fact.disposition == Disposition::Succeeded + || (fact.workflow == SccmClientExtendedWorkflow::Compliance + && fact.disposition == Disposition::NonCompliant) + }) + .map(|fact| fact.phase) + .max(); + let evidence = if terminal.is_empty() { + group + .iter() + .map(|fact| fact.evidence.reference.clone()) + .collect() + } else { + terminal + .iter() + .map(|fact| fact.evidence.reference.clone()) + .collect() + }; + let decisive = terminal + .iter() + .copied() + .max_by_key(|fact| fact.phase) + .unwrap_or_else(|| group.iter().max_by_key(|fact| fact.phase).unwrap_or(first)); + let phase = decisive.phase; + let transaction_id = format!( + "client-extended:{}:{}", + workflow_name(first.workflow), + tuple_discriminator(first.workflow, &first.tuple) + ); + SccmClientExtendedTransaction { + transaction_id, + workflow: first.workflow, + profile_id: first.profile_id.clone(), + phase, + source_basename: decisive.source_basename.clone(), + state, + last_successful_phase, + keys: first.keys.clone(), + evidence, + coverage_gap_artifact_ids: Vec::new(), + } +} + +fn finding_for(transaction: &SccmClientExtendedTransaction) -> Option { + let class = match transaction.state { + SccmClientExtendedState::Failed | SccmClientExtendedState::EvaluatedNonCompliant => { + SccmFindingClass::Symptom + } + SccmClientExtendedState::BlockedOrDeferred => SccmFindingClass::BlockedOrDeferred, + SccmClientExtendedState::Contradictory | SccmClientExtendedState::InsufficientEvidence => { + SccmFindingClass::InsufficientEvidence + } + SccmClientExtendedState::InProgress + | SccmClientExtendedState::Succeeded + | SccmClientExtendedState::EvaluatedCompliant + | SccmClientExtendedState::Remediated + | SccmClientExtendedState::Recovered => return None, + }; + let source_basename = transaction.source_basename.as_str(); + let next_artifact = Some(SccmClientExtendedArtifactRequest { + logical_artifact_id: workflow_artifact_id(transaction.workflow).to_owned(), + source_basename: source_basename.to_owned(), + reason: format!( + "Inspect the same exact {} key in this admitted {} source.", + workflow_name(transaction.workflow), + workflow_name(transaction.workflow) + ), + }); + Some(SccmClientExtendedFinding { + finding_id: format!("finding:client-extended:{}", transaction.transaction_id), + subject_id: transaction.transaction_id.clone(), + workflow: transaction.workflow, + role: SccmRole::Client, + class, + severity: match transaction.state { + SccmClientExtendedState::Failed => Severity::Error, + SccmClientExtendedState::EvaluatedNonCompliant + | SccmClientExtendedState::BlockedOrDeferred + | SccmClientExtendedState::Contradictory + | SccmClientExtendedState::InsufficientEvidence => Severity::Warning, + SccmClientExtendedState::InProgress + | SccmClientExtendedState::Succeeded + | SccmClientExtendedState::EvaluatedCompliant + | SccmClientExtendedState::Remediated + | SccmClientExtendedState::Recovered => Severity::Info, + }, + state: transaction.state, + phase: transaction.phase, + confidence: SccmKeyConfidence::Low, + keys: transaction.keys.clone(), + next_artifact, + evidence: transaction.evidence.clone(), + }) +} + +fn tuple_discriminator(workflow: SccmClientExtendedWorkflow, tuple: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(workflow_name(workflow).as_bytes()); + hasher.update([0]); + hasher.update(tuple.as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn logical_artifact_for_basename(source_basename: &str) -> &'static str { + match canonical_family_basename(source_basename).as_ref() { + "InventoryAgent.log" | "InventoryProvider.log" | "InventoryAgentProvider.log" => { + "client-inventory" + } + "CIAgent.log" | "StateMessage.log" => "client-policy-state", + "CITaskMgr.log" | "DCMAgent.log" | "DCMReporting.log" => "client-compliance", + "SWMTRReportGen.log" => "client-metering", + _ => unreachable!("extended analysis only calls this for admitted source families"), + } +} + +fn canonical_family_basename(basename: &str) -> Cow<'_, str> { + basename.strip_suffix(".lo_").map_or_else( + || Cow::Borrowed(basename), + |stem| Cow::Owned(format!("{stem}.log")), + ) +} + +fn workflow_artifact_id(workflow: SccmClientExtendedWorkflow) -> &'static str { + match workflow { + SccmClientExtendedWorkflow::Inventory => "client-inventory", + SccmClientExtendedWorkflow::Compliance => "client-compliance", + SccmClientExtendedWorkflow::Metering => "client-metering", + } +} + +fn ordered_recovery(failures: &[&Fact], successes: &[&Fact]) -> bool { + let Some(failure) = failures.last() else { + return false; + }; + let Some(success) = successes.first() else { + return false; + }; + failure.phase == success.phase + && failure.evidence.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc + && success.evidence.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc + && failure.evidence.timestamp.utc_millis.is_some() + && success.evidence.timestamp.utc_millis.is_some() + && failure.evidence.timestamp.utc_millis < success.evidence.timestamp.utc_millis +} + +fn parse_phase( + workflow: SccmClientExtendedWorkflow, + value: &str, +) -> Option { + let phase = match value.to_ascii_lowercase().as_str() { + "collect" => SccmClientExtendedPhase::Collect, + "provider" => SccmClientExtendedPhase::Provider, + "serialize" => SccmClientExtendedPhase::Serialize, + "queue" => SccmClientExtendedPhase::Queue, + "evaluate" => SccmClientExtendedPhase::Evaluate, + "remediate" => SccmClientExtendedPhase::Remediate, + "aggregate" => SccmClientExtendedPhase::Aggregate, + "report" => SccmClientExtendedPhase::Report, + _ => return None, + }; + let valid = match workflow { + SccmClientExtendedWorkflow::Inventory => matches!( + phase, + SccmClientExtendedPhase::Collect + | SccmClientExtendedPhase::Provider + | SccmClientExtendedPhase::Serialize + | SccmClientExtendedPhase::Queue + | SccmClientExtendedPhase::Report + ), + SccmClientExtendedWorkflow::Compliance => { + matches!( + phase, + SccmClientExtendedPhase::Evaluate + | SccmClientExtendedPhase::Remediate + | SccmClientExtendedPhase::Report + ) + } + SccmClientExtendedWorkflow::Metering => { + matches!( + phase, + SccmClientExtendedPhase::Collect + | SccmClientExtendedPhase::Aggregate + | SccmClientExtendedPhase::Report + ) + } + }; + valid.then_some(phase) +} + +fn source_allows_phase(source: &str, phase: SccmClientExtendedPhase) -> bool { + match source { + "InventoryAgent.log" => phase == SccmClientExtendedPhase::Collect, + "InventoryProvider.log" => matches!( + phase, + SccmClientExtendedPhase::Provider | SccmClientExtendedPhase::Serialize + ), + "InventoryAgentProvider.log" => matches!( + phase, + SccmClientExtendedPhase::Queue | SccmClientExtendedPhase::Report + ), + "CIAgent.log" => phase == SccmClientExtendedPhase::Evaluate, + "CITaskMgr.log" => matches!( + phase, + SccmClientExtendedPhase::Evaluate | SccmClientExtendedPhase::Remediate + ), + "DCMAgent.log" => phase == SccmClientExtendedPhase::Remediate, + "DCMReporting.log" => matches!( + phase, + SccmClientExtendedPhase::Evaluate | SccmClientExtendedPhase::Report + ), + "StateMessage.log" => phase == SccmClientExtendedPhase::Report, + "SWMTRReportGen.log" => matches!( + phase, + SccmClientExtendedPhase::Collect + | SccmClientExtendedPhase::Aggregate + | SccmClientExtendedPhase::Report + ), + _ => false, + } +} + +fn parse_disposition(value: &str) -> Disposition { + match value.to_ascii_lowercase().as_str() { + "succeeded" | "compliant" => Disposition::Succeeded, + "failed" => Disposition::Failed, + "noncompliant" => Disposition::NonCompliant, + "deferred" | "pending" => Disposition::Deferred, + _ => Disposition::Other, + } +} + +fn parse_unique_fields(message: &str) -> Result, ()> { + let mut fields = BTreeMap::new(); + for token in message.split_whitespace() { + let Some((key, value)) = token.split_once('=') else { + continue; + }; + let key = key.to_ascii_lowercase(); + if fields.insert(key, value.to_owned()).is_some() { + return Err(()); + } + } + Ok(fields) +} + +fn profile_id(_workflow: SccmClientExtendedWorkflow) -> &'static str { + SCCM_EXPERIMENTAL_KEY_PROFILE_ID +} + +fn workflow_name(workflow: SccmClientExtendedWorkflow) -> &'static str { + match workflow { + SccmClientExtendedWorkflow::Inventory => "inventory", + SccmClientExtendedWorkflow::Compliance => "compliance", + SccmClientExtendedWorkflow::Metering => "metering", + } +} + +fn observe( + observations: &mut Vec, + context: &ArtifactContext, + evidence: &SccmEvidence, + reason: &str, +) { + observations.push(SccmClientExtendedObservation { + workflow: context.workflow, + reason: reason.to_owned(), + sources: vec![context.source.clone()], + evidence: vec![evidence.reference.clone()], + }); +} 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..efd8ecb60 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -0,0 +1,38 @@ +//! Pure SCCM client diagnostics. +//! +//! This module accepts already-supplied metadata. Native discovery and capture +//! remain outside `cmtraceopen-parser`. + +pub(crate) const TASK_SEQUENCE_TEST_PROFILE_ID: &str = "task-sequence-client-5.00.test-v1"; +pub(crate) const TASK_SEQUENCE_TEST_VERSION: &str = "5.00.TEST.0000"; + +pub(crate) mod admission; +mod deployment; +mod health; +mod intake; +mod inventory; +mod policy; +mod task_sequence; +mod updates; + +#[cfg(test)] +mod admission_tests; +#[cfg(test)] +mod authority_contract_tests; + +pub use admission::{ + admit_client_evidence, SccmClientAdmittedEvidence, SccmClientCapturedPayload, + SccmClientEvidenceAdmissionError, +}; +pub use deployment::*; +pub use health::*; +pub use intake::*; +pub use inventory::{ + analyze_client_extended, SccmClientExtendedAnalysis, SccmClientExtendedArtifactRequest, + SccmClientExtendedCoverage, SccmClientExtendedFinding, SccmClientExtendedObservation, + SccmClientExtendedPhase, SccmClientExtendedSourceCitation, SccmClientExtendedState, + SccmClientExtendedTransaction, SccmClientExtendedWorkflow, +}; +pub use policy::*; +pub use task_sequence::*; +pub use updates::*; diff --git a/crates/cmtraceopen-parser/src/sccm/client/policy.rs b/crates/cmtraceopen-parser/src/sccm/client/policy.rs new file mode 100644 index 000000000..89694b496 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/policy.rs @@ -0,0 +1,1410 @@ +//! SCCM client policy and assignment reduction over sealed client admission. +//! +//! The public entry point accepts the canonical intake inputs because they are +//! the only public way to obtain the opaque admitted-evidence capability. Raw +//! normalized evidence, extraction profiles, keys, and findings are never +//! accepted from callers. + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use thiserror::Error; + +use crate::models::log_entry::Severity; +use crate::sccm::{ + SccmArtifactFamily, SccmArtifactRequest, SccmClientAdmittedEvidence, SccmClientCapturedPayload, + SccmClientEvidenceAdmissionError, SccmClientIntakeAssessment, SccmClientIntakeBundle, + SccmConfidence, SccmCorrelationKey, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, + SccmEvidenceRef, SccmExtractionGapKind, SccmFinding, SccmFindingBuilder, SccmFindingClass, + SccmFindingCoverageGap, SccmFindingValidationError, SccmKeyConfidence, SccmPhase, SccmRole, + SccmRotation, SccmTerminalEvidence, SccmTimeOrderingState, SccmTimestamp, + SCCM_POLICY_KEY_PROFILE_ID, +}; + +use super::admit_client_evidence; + +const POLICY_AGENT_GROUP: &str = "client-policy-agent"; +const POLICY_STATE_GROUP: &str = "client-policy-state"; +const CLIENT_LOCATION_GROUP: &str = "client-location"; + +type EvidenceIdentity = (String, String, Option, Option); + +const PHASES: [SccmPolicyPhase; 7] = [ + SccmPolicyPhase::Request, + SccmPolicyPhase::Download, + SccmPolicyPhase::TransferAuth, + SccmPolicyPhase::Persist, + SccmPolicyPhase::Schedule, + SccmPolicyPhase::Evaluate, + SccmPolicyPhase::Report, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmPolicyPhase { + Request, + Download, + TransferAuth, + Persist, + Schedule, + Evaluate, + Report, +} + +impl SccmPolicyPhase { + fn rank(self) -> u8 { + match self { + Self::Request => 0, + Self::Download => 1, + Self::TransferAuth => 2, + Self::Persist => 3, + Self::Schedule => 4, + Self::Evaluate => 5, + Self::Report => 6, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmPolicyObservationOutcome { + Succeeded, + Failed, + Deferred, + Observed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmPolicyState { + Succeeded, + Failed, + Deferred, + Incomplete, + Contradictory, + Observed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmPolicyClassification { + Success, + ConfirmedFailure, + BlockedOrDeferred, + InsufficientEvidence, + ContradictoryEvidence, + LowConfidenceSymptom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmPolicyCondition { + NoAssignment, + StaleAssignment, + TransferAuthenticationFailure, + DownloadFailure, + ProcessingFailure, + SchedulerBlocked, + EvaluationFailure, + ReportingFailure, + CoverageGap, + UnknownProfile, + MalformedKey, + OrderingUnavailable, + ConflictingEvidence, + RotationSplit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmPolicyProfileSelectionState { + Selected, + UnvalidatedVersion, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmPolicyExtractionProfile { + pub selection_state: SccmPolicyProfileSelectionState, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_id: Option, + /// The only validated policy profile is the committed synthetic fixture + /// profile. This is not a claim of stability for any production ConfigMgr + /// version. + pub synthetic_fixture_only: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmPolicyCoverage { + pub logical_artifact_id: String, + pub state: SccmCoverageState, + pub artifact_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmPolicyProfileGap { + pub artifact_id: String, + pub condition: SccmPolicyCondition, + pub selected_configmgr_version: Option, + pub evidence: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmPolicyTransactionKey { + pub assignment_id: String, + pub policy_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + pub extraction_profile_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmPolicyObservation { + pub observation_id: String, + pub phase: SccmPolicyPhase, + pub outcome: SccmPolicyObservationOutcome, + pub terminal: bool, + pub timestamp: SccmTimestamp, + pub evidence: SccmEvidenceRef, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmPolicyTransaction { + pub transaction_id: String, + pub key: SccmPolicyTransactionKey, + pub phase: SccmPolicyPhase, + pub state: SccmPolicyState, + pub classification: SccmPolicyClassification, + pub condition: Option, + pub last_confirmed_phase: Option, + pub confidence: SccmConfidence, + pub correlation_keys: Vec, + pub observations: Vec, + pub evidence: Vec, + pub coverage_gaps: Vec, + pub next_artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmPolicySourceLocalObservation { + pub observation_id: String, + pub state: SccmPolicyState, + pub classification: SccmPolicyClassification, + pub condition: SccmPolicyCondition, + pub confidence: SccmConfidence, + pub correlation_eligible: bool, + pub artifact_ids: Vec, + pub evidence: Vec, + pub next_artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmPolicyAnalysis { + pub workflow: String, + pub state_chain: Vec, + pub extraction_profile: SccmPolicyExtractionProfile, + pub coverage: Vec, + pub profile_gaps: Vec, + pub transactions: Vec, + pub source_local_observations: Vec, + pub findings: Vec, + pub artifact_requests: Vec, + pub cross_source_correlation_performed: bool, + pub time_only_causality_allowed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SccmPolicyError { + #[error("client policy evidence admission failed: {0}")] + Admission(#[from] SccmClientEvidenceAdmissionError), + #[error("client policy finding failed the shared finding contract: {0:?}")] + InvalidFinding(SccmFindingValidationError), + #[error("client policy admitted evidence did not retain its integrity seal")] + IntegrityViolation, +} + +#[derive(Debug, Clone)] +struct PolicyFact { + assignment_id: String, + policy_id: String, + request_id: Option, + phase: SccmPolicyPhase, + outcome: SccmPolicyObservationOutcome, + condition: Option, + terminal: bool, + timestamp: SccmTimestamp, + reference: SccmEvidenceRef, + keys: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PhaseResolution { + Succeeded, + Failed, + Deferred, + Contradictory, +} + +#[derive(Debug)] +struct ReducedTransaction { + transaction: SccmPolicyTransaction, + finding: Option, +} + +/// Reduces one client policy bundle through canonical intake and sealed raw +/// CCM admission. Callers cannot supply normalized evidence, profiles, keys, +/// or findings to this boundary. +pub fn analyze_client_policy( + bundle: &SccmClientIntakeBundle, + assessment: &SccmClientIntakeAssessment, + payloads: &[SccmClientCapturedPayload], +) -> Result { + let admitted = admit_client_evidence(bundle, assessment, payloads)?; + admitted.verify_integrity()?; + reduce_policy(assessment, &admitted) +} + +fn reduce_policy( + assessment: &SccmClientIntakeAssessment, + admitted: &SccmClientAdmittedEvidence, +) -> Result { + let evidence = admitted.evidence()?; + let policy_evidence = evidence + .iter() + .filter(|entry| is_policy_component(entry.component.as_deref())) + .collect::>(); + let extraction_by_evidence = extraction_results(admitted, &policy_evidence)?; + + let mut profile_gaps = Vec::new(); + let mut source_local_observations = rotation_observations(assessment); + let mut facts = Vec::new(); + for entry in policy_evidence { + let identity = evidence_identity(&entry.reference); + let Some(extraction) = extraction_by_evidence.get(&identity) else { + return Err(SccmPolicyError::IntegrityViolation); + }; + if extraction + .gaps + .iter() + .any(|gap| gap.kind == SccmExtractionGapKind::UnvalidatedVersion) + { + profile_gaps.push(SccmPolicyProfileGap { + artifact_id: entry.reference.artifact_id.clone(), + condition: SccmPolicyCondition::UnknownProfile, + selected_configmgr_version: extraction + .gaps + .first() + .and_then(|gap| gap.selected_configmgr_version.clone()), + evidence: Some(entry.reference.clone()), + }); + source_local_observations.push(source_local_from_evidence( + entry, + SccmPolicyCondition::UnknownProfile, + request_for_group(POLICY_AGENT_GROUP, SccmPolicyCondition::UnknownProfile), + )); + continue; + } + if extraction + .gaps + .iter() + .any(|gap| gap.kind == SccmExtractionGapKind::MalformedCandidate) + { + profile_gaps.push(SccmPolicyProfileGap { + artifact_id: entry.reference.artifact_id.clone(), + condition: SccmPolicyCondition::MalformedKey, + selected_configmgr_version: None, + evidence: Some(entry.reference.clone()), + }); + } + + let Some(phase) = phase_from_message(&entry.message) else { + if message_has_no_assignment(&entry.message) { + source_local_observations.push(source_local_from_evidence( + entry, + SccmPolicyCondition::NoAssignment, + Vec::new(), + )); + } + continue; + }; + let Some(outcome) = outcome_from_message(&entry.message) else { + continue; + }; + let Some(assignment_id) = + unique_exact_key(extraction, SccmCorrelationKeyKind::AssignmentId) + else { + source_local_observations.push(source_local_from_evidence( + entry, + SccmPolicyCondition::MalformedKey, + request_for_group(POLICY_AGENT_GROUP, SccmPolicyCondition::MalformedKey), + )); + continue; + }; + let Some(policy_id) = unique_exact_key(extraction, SccmCorrelationKeyKind::PolicyId) else { + source_local_observations.push(source_local_from_evidence( + entry, + SccmPolicyCondition::MalformedKey, + request_for_group(POLICY_AGENT_GROUP, SccmPolicyCondition::MalformedKey), + )); + continue; + }; + let request_id = optional_unique_exact_key(extraction, SccmCorrelationKeyKind::RequestId); + let keys = extraction + .keys + .iter() + .filter(|key| allowed_policy_key(&key.kind)) + .cloned() + .collect(); + facts.push(PolicyFact { + assignment_id, + policy_id, + request_id, + phase, + outcome, + condition: condition_from_message(&entry.message, phase, outcome), + terminal: entry.message.to_ascii_lowercase().contains("terminal"), + timestamp: entry.timestamp.clone(), + reference: entry.reference.clone(), + keys, + }); + } + + let conflicting_assignments = conflicting_assignment_ids(&facts); + let mut grouped = BTreeMap::<(String, String), Vec>::new(); + for fact in facts { + if conflicting_assignments.contains(&fact.assignment_id) { + source_local_observations.push(source_local_from_fact( + &fact, + SccmPolicyCondition::ConflictingEvidence, + )); + continue; + } + grouped + .entry((fact.assignment_id.clone(), fact.policy_id.clone())) + .or_default() + .push(fact); + } + + let mut transactions = Vec::new(); + let mut findings = Vec::new(); + for (_, facts) in grouped { + let reduced = reduce_transaction(assessment, facts)?; + if let Some(finding) = reduced.finding { + findings.push(finding); + } + transactions.push(reduced.transaction); + } + transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_id)); + findings.sort_by(|left, right| left.finding_id.cmp(&right.finding_id)); + profile_gaps.sort_by(profile_gap_order); + profile_gaps.dedup(); + source_local_observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + source_local_observations.dedup_by(|left, right| left.observation_id == right.observation_id); + let cross_source_correlation_performed = transactions + .iter() + .any(|transaction| transaction_spans_source_groups(assessment, transaction)); + + let mut artifact_requests = transactions + .iter() + .flat_map(|transaction| transaction.next_artifacts.iter().cloned()) + .chain( + source_local_observations + .iter() + .flat_map(|observation| observation.next_artifacts.iter().cloned()), + ) + .collect::>(); + artifact_requests.sort_by(request_order); + artifact_requests.dedup_by(|left, right| request_order(left, right).is_eq()); + + let extraction_profile = if !profile_gaps.is_empty() { + SccmPolicyExtractionProfile { + selection_state: SccmPolicyProfileSelectionState::UnvalidatedVersion, + profile_id: None, + synthetic_fixture_only: true, + } + } else if assessment + .physical_artifacts + .iter() + .any(|fragment| is_policy_basename(&fragment.basename)) + { + SccmPolicyExtractionProfile { + selection_state: SccmPolicyProfileSelectionState::Selected, + profile_id: Some(SCCM_POLICY_KEY_PROFILE_ID.to_owned()), + synthetic_fixture_only: true, + } + } else { + SccmPolicyExtractionProfile { + selection_state: SccmPolicyProfileSelectionState::Unavailable, + profile_id: None, + synthetic_fixture_only: true, + } + }; + + Ok(SccmPolicyAnalysis { + workflow: "policyAndAssignment".to_owned(), + state_chain: PHASES.to_vec(), + extraction_profile, + coverage: policy_coverage(assessment), + profile_gaps, + transactions, + source_local_observations, + findings, + artifact_requests, + cross_source_correlation_performed, + time_only_causality_allowed: false, + }) +} + +fn transaction_spans_source_groups( + assessment: &SccmClientIntakeAssessment, + transaction: &SccmPolicyTransaction, +) -> bool { + let artifact_ids = transaction + .evidence + .iter() + .map(|reference| reference.artifact_id.as_str()) + .collect::>(); + assessment + .groups + .iter() + .filter(|group| { + group + .fragments + .iter() + .any(|fragment| artifact_ids.contains(fragment.artifact_id.as_str())) + }) + .take(2) + .count() + > 1 +} + +fn extraction_results( + admitted: &SccmClientAdmittedEvidence, + evidence: &[&SccmEvidence], +) -> Result, SccmPolicyError> { + let artifact_ids = evidence + .iter() + .map(|entry| entry.reference.artifact_id.clone()) + .collect::>(); + let mut results = BTreeMap::new(); + for artifact_id in artifact_ids { + let extraction = admitted.extract_keys_for_artifact(&artifact_id)?; + if extraction.artifact_family() != &SccmArtifactFamily::ClientPolicy { + continue; + } + let artifact_evidence = evidence + .iter() + .filter(|entry| entry.reference.artifact_id == artifact_id) + .collect::>(); + if artifact_evidence.len() != extraction.results().len() { + return Err(SccmPolicyError::IntegrityViolation); + } + for (entry, result) in artifact_evidence.into_iter().zip(extraction.results()) { + results.insert(evidence_identity(&entry.reference), result.clone()); + } + } + Ok(results) +} + +fn reduce_transaction( + assessment: &SccmClientIntakeAssessment, + mut facts: Vec, +) -> Result { + facts.sort_by(fact_order); + let assignment_id = facts[0].assignment_id.clone(); + let policy_id = facts[0].policy_id.clone(); + let transaction_id = format!("policy:assignment:{assignment_id}"); + let request_ids = facts + .iter() + .filter_map(|fact| fact.request_id.clone()) + .collect::>(); + let request_id = (request_ids.len() == 1) + .then(|| request_ids.iter().next().cloned()) + .flatten(); + + let mut observations = facts.iter().map(observation_from_fact).collect::>(); + observations.sort_by(observation_order); + let mut evidence = facts + .iter() + .map(|fact| fact.reference.clone()) + .collect::>(); + evidence.sort_by(evidence_order); + evidence.dedup(); + let mut correlation_keys = facts + .iter() + .flat_map(|fact| fact.keys.iter().cloned()) + .collect::>(); + correlation_keys.sort_by(correlation_key_order); + correlation_keys.dedup(); + + let mut resolutions = BTreeMap::new(); + let mut representatives = BTreeMap::new(); + for phase in PHASES { + let phase_facts = facts + .iter() + .filter(|fact| fact.phase == phase) + .collect::>(); + if phase_facts.is_empty() { + continue; + } + let (resolution, representative) = resolve_phase(&phase_facts); + resolutions.insert(phase, resolution); + representatives.insert(phase, representative.reference.clone()); + } + + let chronology_conflict = cross_phase_chronology_conflict(&facts); + let decisive = PHASES.iter().find_map(|phase| { + resolutions.get(phase).and_then(|resolution| { + (*resolution != PhaseResolution::Succeeded).then_some((*phase, *resolution)) + }) + }); + + let (phase, state, classification, condition, last_confirmed_phase, mut confidence) = + if let Some((_earlier, later)) = chronology_conflict { + ( + later, + SccmPolicyState::Contradictory, + SccmPolicyClassification::ContradictoryEvidence, + Some(SccmPolicyCondition::OrderingUnavailable), + last_confirmed_successful_prefix(&facts), + SccmConfidence::Low, + ) + } else if let Some((phase, resolution)) = decisive { + let last = last_success_before(&resolutions, phase); + match resolution { + PhaseResolution::Failed => { + let condition = facts + .iter() + .find(|fact| { + fact.phase == phase + && fact.outcome == SccmPolicyObservationOutcome::Failed + }) + .and_then(|fact| fact.condition) + .or(Some(default_failure_condition(phase))); + let terminal = facts.iter().any(|fact| { + fact.phase == phase + && fact.outcome == SccmPolicyObservationOutcome::Failed + && fact.terminal + }); + if terminal { + ( + phase, + SccmPolicyState::Failed, + SccmPolicyClassification::ConfirmedFailure, + condition, + last, + SccmConfidence::High, + ) + } else { + ( + phase, + SccmPolicyState::Observed, + SccmPolicyClassification::LowConfidenceSymptom, + condition, + last, + SccmConfidence::Low, + ) + } + } + PhaseResolution::Deferred => ( + phase, + SccmPolicyState::Deferred, + SccmPolicyClassification::BlockedOrDeferred, + facts + .iter() + .find(|fact| { + fact.phase == phase + && fact.outcome == SccmPolicyObservationOutcome::Deferred + }) + .and_then(|fact| fact.condition) + .or(Some(SccmPolicyCondition::SchedulerBlocked)), + last, + SccmConfidence::High, + ), + PhaseResolution::Contradictory => ( + phase, + SccmPolicyState::Contradictory, + SccmPolicyClassification::ContradictoryEvidence, + Some( + if facts.iter().filter(|fact| fact.phase == phase).any(|fact| { + fact.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + }) { + SccmPolicyCondition::OrderingUnavailable + } else { + SccmPolicyCondition::ConflictingEvidence + }, + ), + last, + SccmConfidence::Low, + ), + PhaseResolution::Succeeded => unreachable!(), + } + } else if resolutions.get(&SccmPolicyPhase::Report) == Some(&PhaseResolution::Succeeded) { + ( + SccmPolicyPhase::Report, + SccmPolicyState::Succeeded, + SccmPolicyClassification::Success, + None, + Some(SccmPolicyPhase::Report), + SccmConfidence::High, + ) + } else { + let last = last_contiguous_success(&resolutions); + let missing = first_missing_required_phase(&resolutions, last); + ( + missing, + SccmPolicyState::Incomplete, + SccmPolicyClassification::InsufficientEvidence, + Some(SccmPolicyCondition::CoverageGap), + last, + SccmConfidence::Moderate, + ) + }; + + let mut coverage_gaps = relevant_coverage_gaps(assessment, phase, state, condition); + let mut next_artifacts = next_artifacts_for(phase, state, condition); + if condition == Some(SccmPolicyCondition::TransferAuthenticationFailure) { + confidence = SccmConfidence::Moderate; + let location_gap = finding_gap_for_group(assessment, CLIENT_LOCATION_GROUP); + if let Some(gap) = location_gap { + coverage_gaps.push(gap); + } + next_artifacts = request_for_group( + CLIENT_LOCATION_GROUP, + SccmPolicyCondition::TransferAuthenticationFailure, + ); + } + coverage_gaps.sort_by(coverage_gap_order); + coverage_gaps.dedup(); + next_artifacts.sort_by(request_order); + next_artifacts.dedup_by(|left, right| request_order(left, right).is_eq()); + + let transaction = SccmPolicyTransaction { + transaction_id: transaction_id.clone(), + key: SccmPolicyTransactionKey { + assignment_id, + policy_id, + request_id, + extraction_profile_id: SCCM_POLICY_KEY_PROFILE_ID.to_owned(), + }, + phase, + state, + classification, + condition, + last_confirmed_phase, + confidence, + correlation_keys, + observations, + evidence, + coverage_gaps, + next_artifacts, + }; + let finding = build_transaction_finding(&transaction, &facts, &representatives)?; + Ok(ReducedTransaction { + transaction, + finding, + }) +} + +fn build_transaction_finding( + transaction: &SccmPolicyTransaction, + facts: &[PolicyFact], + representatives: &BTreeMap, +) -> Result, SccmPolicyError> { + if transaction.state == SccmPolicyState::Succeeded { + return Ok(None); + } + let mut finding_evidence = if transaction.state == SccmPolicyState::Contradictory { + facts + .iter() + .filter(|fact| fact.phase == transaction.phase) + .map(|fact| fact.reference.clone()) + .collect::>() + } else { + representatives + .get(&transaction.phase) + .cloned() + .into_iter() + .collect::>() + }; + if finding_evidence.is_empty() { + finding_evidence = transaction.evidence.last().cloned().into_iter().collect(); + } + finding_evidence.sort_by(evidence_order); + finding_evidence.dedup(); + + let terminal_evidence = if transaction.state == SccmPolicyState::Failed { + facts + .iter() + .filter(|fact| { + fact.phase == transaction.phase + && fact.outcome == SccmPolicyObservationOutcome::Failed + && fact.terminal + }) + .map(|fact| SccmTerminalEvidence::observed_failure(fact.reference.clone())) + .collect() + } else { + Vec::new() + }; + let finding_keys = transaction + .correlation_keys + .iter() + .filter(|key| { + key.evidence + .as_ref() + .is_some_and(|reference| finding_evidence.contains(reference)) + }) + .cloned() + .collect(); + let class = match transaction.state { + SccmPolicyState::Failed => SccmFindingClass::ConfirmedFailure, + SccmPolicyState::Deferred => SccmFindingClass::BlockedOrDeferred, + SccmPolicyState::Incomplete if !transaction.coverage_gaps.is_empty() => { + SccmFindingClass::InsufficientEvidence + } + SccmPolicyState::Incomplete + | SccmPolicyState::Contradictory + | SccmPolicyState::Observed => SccmFindingClass::Symptom, + SccmPolicyState::Succeeded => return Ok(None), + }; + let finding = SccmFindingBuilder::new(format!( + "finding:{}:{:?}", + transaction.transaction_id, transaction.phase + )) + .class(class) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(if transaction.state == SccmPolicyState::Failed { + Severity::Error + } else { + Severity::Warning + }) + .confidence(transaction.confidence) + .title("Client policy workflow evidence") + .summary("The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.") + .evidence(finding_evidence) + .terminal_evidence(terminal_evidence) + .coverage_gaps(transaction.coverage_gaps.clone()) + .correlation_keys(finding_keys) + .next_artifacts(transaction.next_artifacts.clone()) + .build() + .map_err(SccmPolicyError::InvalidFinding)?; + Ok(Some(finding)) +} + +fn resolve_phase<'a>(facts: &[&'a PolicyFact]) -> (PhaseResolution, &'a PolicyFact) { + let outcomes = facts + .iter() + .map(|fact| fact.outcome) + .collect::>(); + if outcomes.len() == 1 { + let representative = facts + .iter() + .copied() + .max_by(|left, right| comparable_fact_order(left, right)) + .expect("phase has facts"); + return ( + resolution_for_outcome(representative.outcome), + representative, + ); + } + if facts.iter().any(|fact| { + fact.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + || fact.timestamp.utc_millis.is_none() + }) { + return (PhaseResolution::Contradictory, facts[0]); + } + let latest_utc = facts + .iter() + .filter_map(|fact| fact.timestamp.utc_millis) + .max() + .expect("comparable phase facts have UTC"); + let latest = facts + .iter() + .copied() + .filter(|fact| fact.timestamp.utc_millis == Some(latest_utc)) + .collect::>(); + let latest_outcomes = latest + .iter() + .map(|fact| fact.outcome) + .collect::>(); + if latest_outcomes.len() != 1 { + return (PhaseResolution::Contradictory, latest[0]); + } + let representative = latest + .into_iter() + .max_by(|left, right| evidence_order(&left.reference, &right.reference)) + .expect("latest facts are nonempty"); + ( + resolution_for_outcome(representative.outcome), + representative, + ) +} + +fn resolution_for_outcome(outcome: SccmPolicyObservationOutcome) -> PhaseResolution { + match outcome { + SccmPolicyObservationOutcome::Succeeded => PhaseResolution::Succeeded, + SccmPolicyObservationOutcome::Failed => PhaseResolution::Failed, + SccmPolicyObservationOutcome::Deferred => PhaseResolution::Deferred, + SccmPolicyObservationOutcome::Observed => PhaseResolution::Contradictory, + } +} + +fn cross_phase_chronology_conflict( + facts: &[PolicyFact], +) -> Option<(SccmPolicyPhase, SccmPolicyPhase)> { + for earlier in facts { + for later in facts { + if earlier.phase.rank() >= later.phase.rank() { + continue; + } + let comparable = earlier.timestamp.ordering_state + == SccmTimeOrderingState::NormalizedUtc + && later.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc + && earlier.timestamp.utc_millis.is_some() + && later.timestamp.utc_millis.is_some(); + if !comparable || earlier.timestamp.utc_millis >= later.timestamp.utc_millis { + return Some((earlier.phase, later.phase)); + } + } + } + None +} + +fn last_success_before( + resolutions: &BTreeMap, + phase: SccmPolicyPhase, +) -> Option { + PHASES + .iter() + .copied() + .filter(|candidate| candidate.rank() < phase.rank()) + .filter(|candidate| resolutions.get(candidate) == Some(&PhaseResolution::Succeeded)) + .max_by_key(|candidate| candidate.rank()) +} + +fn last_contiguous_success( + resolutions: &BTreeMap, +) -> Option { + let mut last = None; + for phase in PHASES { + if phase == SccmPolicyPhase::TransferAuth && !resolutions.contains_key(&phase) { + continue; + } + if resolutions.get(&phase) != Some(&PhaseResolution::Succeeded) { + break; + } + last = Some(phase); + } + last +} + +fn last_confirmed_successful_prefix(facts: &[PolicyFact]) -> Option { + let mut last_phase = None; + let mut last_utc_millis = None; + for phase in PHASES { + let phase_facts = facts + .iter() + .filter(|fact| fact.phase == phase) + .collect::>(); + if phase_facts.is_empty() { + if phase == SccmPolicyPhase::TransferAuth { + continue; + } + break; + } + let (resolution, representative) = resolve_phase(&phase_facts); + if resolution != PhaseResolution::Succeeded + || representative.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + { + break; + } + let Some(utc_millis) = representative.timestamp.utc_millis else { + break; + }; + if last_utc_millis.is_some_and(|confirmed| utc_millis <= confirmed) { + break; + } + last_phase = Some(phase); + last_utc_millis = Some(utc_millis); + } + last_phase +} + +fn first_missing_required_phase( + resolutions: &BTreeMap, + last: Option, +) -> SccmPolicyPhase { + PHASES + .iter() + .copied() + .filter(|phase| *phase != SccmPolicyPhase::TransferAuth) + .find(|phase| { + last.is_none_or(|confirmed| phase.rank() > confirmed.rank()) + && !resolutions.contains_key(phase) + }) + .unwrap_or(SccmPolicyPhase::Report) +} + +fn phase_from_message(message: &str) -> Option { + let value = message.to_ascii_lowercase(); + if value.contains("authentication") || value.contains("transfer auth") { + Some(SccmPolicyPhase::TransferAuth) + } else if value.contains("request") { + Some(SccmPolicyPhase::Request) + } else if value.contains("download") { + Some(SccmPolicyPhase::Download) + } else if value.contains("persist") || value.contains("processing corrupt") { + Some(SccmPolicyPhase::Persist) + } else if value.contains("schedule") || value.contains("stale assignment") { + Some(SccmPolicyPhase::Schedule) + } else if value.contains("evaluate") { + Some(SccmPolicyPhase::Evaluate) + } else if value.contains("report") || value.contains("status") { + Some(SccmPolicyPhase::Report) + } else { + None + } +} + +fn outcome_from_message(message: &str) -> Option { + let value = message.to_ascii_lowercase(); + if value.contains("failed") || value.contains("corrupt") { + Some(SccmPolicyObservationOutcome::Failed) + } else if value.contains("deferred") || value.contains("blocked") || value.contains("stale") { + Some(SccmPolicyObservationOutcome::Deferred) + } else if value.contains("succeeded") || value.contains("complete") { + Some(SccmPolicyObservationOutcome::Succeeded) + } else { + None + } +} + +fn condition_from_message( + message: &str, + phase: SccmPolicyPhase, + outcome: SccmPolicyObservationOutcome, +) -> Option { + let value = message.to_ascii_lowercase(); + if value.contains("stale") { + Some(SccmPolicyCondition::StaleAssignment) + } else if outcome == SccmPolicyObservationOutcome::Deferred { + Some(SccmPolicyCondition::SchedulerBlocked) + } else if outcome == SccmPolicyObservationOutcome::Failed { + Some( + if value.contains("authentication") || value.contains("transfer auth") { + SccmPolicyCondition::TransferAuthenticationFailure + } else if value.contains("corrupt") || phase == SccmPolicyPhase::Persist { + SccmPolicyCondition::ProcessingFailure + } else { + default_failure_condition(phase) + }, + ) + } else { + None + } +} + +fn default_failure_condition(phase: SccmPolicyPhase) -> SccmPolicyCondition { + match phase { + SccmPolicyPhase::Request | SccmPolicyPhase::TransferAuth => { + SccmPolicyCondition::TransferAuthenticationFailure + } + SccmPolicyPhase::Download => SccmPolicyCondition::DownloadFailure, + SccmPolicyPhase::Persist => SccmPolicyCondition::ProcessingFailure, + SccmPolicyPhase::Schedule => SccmPolicyCondition::SchedulerBlocked, + SccmPolicyPhase::Evaluate => SccmPolicyCondition::EvaluationFailure, + SccmPolicyPhase::Report => SccmPolicyCondition::ReportingFailure, + } +} + +fn message_has_no_assignment(message: &str) -> bool { + let value = message.to_ascii_lowercase(); + value.contains("no assignment") || value.contains("no policy assignment") +} + +fn conflicting_assignment_ids(facts: &[PolicyFact]) -> BTreeSet { + let mut policies = BTreeMap::>::new(); + for fact in facts { + policies + .entry(fact.assignment_id.clone()) + .or_default() + .insert(fact.policy_id.clone()); + } + policies + .into_iter() + .filter_map(|(assignment, policies)| (policies.len() > 1).then_some(assignment)) + .collect() +} + +fn unique_exact_key( + extraction: &crate::sccm::SccmKeyExtractionResult, + kind: SccmCorrelationKeyKind, +) -> Option { + let values = extraction + .keys + .iter() + .filter(|key| key.kind == kind && key.confidence == SccmKeyConfidence::Exact) + .map(|key| key.normalized.clone()) + .collect::>(); + (values.len() == 1) + .then(|| values.into_iter().next()) + .flatten() +} + +fn optional_unique_exact_key( + extraction: &crate::sccm::SccmKeyExtractionResult, + kind: SccmCorrelationKeyKind, +) -> Option { + unique_exact_key(extraction, kind) +} + +fn allowed_policy_key(kind: &SccmCorrelationKeyKind) -> bool { + matches!( + kind, + SccmCorrelationKeyKind::AssignmentId + | SccmCorrelationKeyKind::PolicyId + | SccmCorrelationKeyKind::RequestId + | SccmCorrelationKeyKind::StateMessageId + | SccmCorrelationKeyKind::SiteCode + ) +} + +fn policy_coverage(assessment: &SccmClientIntakeAssessment) -> Vec { + [POLICY_AGENT_GROUP, POLICY_STATE_GROUP] + .into_iter() + .filter_map(|logical_artifact_id| { + assessment.group(logical_artifact_id).map(|group| { + let mut artifact_ids = group + .fragments + .iter() + .map(|fragment| fragment.artifact_id.clone()) + .collect::>(); + artifact_ids.sort(); + artifact_ids.dedup(); + SccmPolicyCoverage { + logical_artifact_id: logical_artifact_id.to_owned(), + state: group.coverage.clone(), + artifact_ids, + } + }) + }) + .collect() +} + +fn relevant_coverage_gaps( + assessment: &SccmClientIntakeAssessment, + phase: SccmPolicyPhase, + state: SccmPolicyState, + condition: Option, +) -> Vec { + if condition == Some(SccmPolicyCondition::TransferAuthenticationFailure) { + return finding_gap_for_group(assessment, CLIENT_LOCATION_GROUP) + .into_iter() + .collect(); + } + if state != SccmPolicyState::Incomplete { + return Vec::new(); + } + let group = if phase.rank() >= SccmPolicyPhase::Evaluate.rank() { + POLICY_STATE_GROUP + } else { + POLICY_AGENT_GROUP + }; + finding_gap_for_group(assessment, group) + .into_iter() + .collect() +} + +fn finding_gap_for_group( + assessment: &SccmClientIntakeAssessment, + group: &str, +) -> Option { + let coverage = assessment.group(group)?.coverage.clone(); + (coverage != SccmCoverageState::Captured).then(|| SccmFindingCoverageGap { + artifact_id: group.to_owned(), + role: SccmRole::Client, + coverage, + }) +} + +fn next_artifacts_for( + phase: SccmPolicyPhase, + state: SccmPolicyState, + condition: Option, +) -> Vec { + if state == SccmPolicyState::Succeeded || state == SccmPolicyState::Failed { + return Vec::new(); + } + if condition == Some(SccmPolicyCondition::TransferAuthenticationFailure) { + return request_for_group(CLIENT_LOCATION_GROUP, condition.expect("condition")); + } + let group = if phase.rank() >= SccmPolicyPhase::Evaluate.rank() { + POLICY_STATE_GROUP + } else { + POLICY_AGENT_GROUP + }; + request_for_group(group, condition.unwrap_or(SccmPolicyCondition::CoverageGap)) +} + +fn request_for_group(group: &str, condition: SccmPolicyCondition) -> Vec { + let (logical_id, reason) = match group { + CLIENT_LOCATION_GROUP => ( + "clientLocation", + "Collect the complete ClientLocation.log file.", + ), + POLICY_STATE_GROUP => ("ciAgent", "Collect the complete CIAgent.log file."), + _ if matches!( + condition, + SccmPolicyCondition::SchedulerBlocked | SccmPolicyCondition::StaleAssignment + ) => + { + ("scheduler", "Collect the complete Scheduler.log file.") + } + _ => ("policyAgent", "Collect the complete PolicyAgent.log file."), + }; + vec![SccmArtifactRequest { + logical_id: logical_id.to_owned(), + role: SccmRole::Client, + reason: reason.to_owned(), + }] +} + +fn rotation_observations( + assessment: &SccmClientIntakeAssessment, +) -> Vec { + let Some(group) = assessment.group(POLICY_AGENT_GROUP) else { + return Vec::new(); + }; + let mut lineages = BTreeMap::, bool, bool)>::new(); + for fragment in &group.fragments { + if fragment.fragment_complete == Some(false) { + if let Some(lineage) = &fragment.rotation_lineage { + let (artifact_ids, has_current, has_lo) = + lineages.entry(lineage.clone()).or_default(); + artifact_ids.push(fragment.artifact_id.clone()); + *has_current |= fragment.rotation == SccmRotation::Current; + *has_lo |= fragment.rotation == SccmRotation::LoUnderscore; + } + } + } + lineages + .into_iter() + .filter_map(|(lineage, (mut artifact_ids, has_current, has_lo))| { + artifact_ids.sort(); + artifact_ids.dedup(); + (has_current && has_lo).then(|| SccmPolicySourceLocalObservation { + observation_id: format!( + "policy-source:rotation:{}:{}", + artifact_ids.join("+"), + lineage + ), + state: SccmPolicyState::Incomplete, + classification: SccmPolicyClassification::InsufficientEvidence, + condition: SccmPolicyCondition::RotationSplit, + confidence: SccmConfidence::Low, + correlation_eligible: false, + artifact_ids, + evidence: Vec::new(), + next_artifacts: request_for_group( + POLICY_AGENT_GROUP, + SccmPolicyCondition::RotationSplit, + ), + }) + }) + .collect() +} + +fn source_local_from_evidence( + evidence: &SccmEvidence, + condition: SccmPolicyCondition, + next_artifacts: Vec, +) -> SccmPolicySourceLocalObservation { + let reference = evidence.reference.clone(); + SccmPolicySourceLocalObservation { + observation_id: format!( + "policy-source:{}:{}-{}:{condition:?}", + reference.artifact_id, + reference.line_start.unwrap_or(0), + reference.line_end.unwrap_or(0) + ), + state: SccmPolicyState::Observed, + classification: SccmPolicyClassification::LowConfidenceSymptom, + condition, + confidence: SccmConfidence::Low, + correlation_eligible: false, + artifact_ids: vec![reference.artifact_id.clone()], + evidence: vec![reference], + next_artifacts, + } +} + +fn source_local_from_fact( + fact: &PolicyFact, + condition: SccmPolicyCondition, +) -> SccmPolicySourceLocalObservation { + let reference = fact.reference.clone(); + SccmPolicySourceLocalObservation { + observation_id: format!( + "policy-source:{}:{}-{}:{condition:?}", + reference.artifact_id, + reference.line_start.unwrap_or(0), + reference.line_end.unwrap_or(0) + ), + state: SccmPolicyState::Contradictory, + classification: SccmPolicyClassification::ContradictoryEvidence, + condition, + confidence: SccmConfidence::Low, + correlation_eligible: false, + artifact_ids: vec![reference.artifact_id.clone()], + evidence: vec![reference], + next_artifacts: request_for_group(POLICY_AGENT_GROUP, condition), + } +} + +fn observation_from_fact(fact: &PolicyFact) -> SccmPolicyObservation { + SccmPolicyObservation { + observation_id: format!( + "policy-observation:{}:{}-{}:{:?}:{:?}", + fact.reference.artifact_id, + fact.reference.line_start.unwrap_or(0), + fact.reference.line_end.unwrap_or(0), + fact.phase, + fact.outcome + ), + phase: fact.phase, + outcome: fact.outcome, + terminal: fact.terminal, + timestamp: fact.timestamp.clone(), + evidence: fact.reference.clone(), + } +} + +fn is_policy_component(component: Option<&str>) -> bool { + component.is_some_and(|component| { + matches!( + component.to_ascii_lowercase().as_str(), + "policyagent" | "scheduler" | "ciagent" | "statemessage" | "statusagent" + ) + }) +} + +fn is_policy_basename(basename: &str) -> bool { + matches!( + basename.to_ascii_lowercase().as_str(), + "policyagent.log" + | "policyagent.lo_" + | "scheduler.log" + | "ciagent.log" + | "statemessage.log" + | "statusagent.log" + ) +} + +fn evidence_identity(reference: &SccmEvidenceRef) -> EvidenceIdentity { + ( + reference.artifact_id.clone(), + reference.entry_id.clone(), + reference.line_start, + reference.line_end, + ) +} + +fn fact_order(left: &PolicyFact, right: &PolicyFact) -> Ordering { + left.phase + .rank() + .cmp(&right.phase.rank()) + .then_with(|| comparable_fact_order(left, right)) +} + +fn comparable_fact_order(left: &PolicyFact, right: &PolicyFact) -> Ordering { + left.timestamp + .utc_millis + .cmp(&right.timestamp.utc_millis) + .then_with(|| evidence_order(&left.reference, &right.reference)) +} + +fn observation_order(left: &SccmPolicyObservation, right: &SccmPolicyObservation) -> Ordering { + left.phase + .rank() + .cmp(&right.phase.rank()) + .then_with(|| evidence_order(&left.evidence, &right.evidence)) + .then_with(|| left.observation_id.cmp(&right.observation_id)) +} + +fn evidence_order(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)) +} + +fn correlation_key_order(left: &SccmCorrelationKey, right: &SccmCorrelationKey) -> Ordering { + key_kind_order(&left.kind) + .cmp(&key_kind_order(&right.kind)) + .then_with(|| left.normalized.cmp(&right.normalized)) + .then_with(|| optional_evidence_order(left.evidence.as_ref(), right.evidence.as_ref())) +} + +fn key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { + match kind { + SccmCorrelationKeyKind::AssignmentId => 0, + SccmCorrelationKeyKind::PolicyId => 1, + SccmCorrelationKeyKind::RequestId => 2, + SccmCorrelationKeyKind::StateMessageId => 3, + SccmCorrelationKeyKind::SiteCode => 4, + _ => 5, + } +} + +fn coverage_gap_order(left: &SccmFindingCoverageGap, right: &SccmFindingCoverageGap) -> Ordering { + left.artifact_id.cmp(&right.artifact_id).then_with(|| { + coverage_state_order(&left.coverage).cmp(&coverage_state_order(&right.coverage)) + }) +} + +fn coverage_state_order(state: &SccmCoverageState) -> u8 { + match state { + SccmCoverageState::Captured => 0, + SccmCoverageState::Absent => 1, + SccmCoverageState::AccessDenied => 2, + SccmCoverageState::Capped => 3, + SccmCoverageState::Skipped => 4, + SccmCoverageState::Unsupported => 5, + SccmCoverageState::ParseFailed => 6, + } +} + +fn request_order(left: &SccmArtifactRequest, right: &SccmArtifactRequest) -> Ordering { + left.logical_id + .cmp(&right.logical_id) + .then_with(|| left.reason.cmp(&right.reason)) +} + +fn profile_gap_order(left: &SccmPolicyProfileGap, right: &SccmPolicyProfileGap) -> Ordering { + left.artifact_id + .cmp(&right.artifact_id) + .then_with(|| optional_evidence_order(left.evidence.as_ref(), right.evidence.as_ref())) +} + +fn optional_evidence_order( + left: Option<&SccmEvidenceRef>, + right: Option<&SccmEvidenceRef>, +) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => evidence_order(left, right), + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs b/crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs new file mode 100644 index 000000000..fcc80732f --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs @@ -0,0 +1,974 @@ +//! Pure Task Sequence reduction over the sealed SCCM client evidence boundary. +//! +//! The only reviewed profile is the synthetic `5.00.TEST.0000` corpus. This +//! module makes no native Windows acceptance claim. Execution identity and +//! observed `_SMSTSLogPath` values remain reducer-private; exported values carry +//! only opaque transaction ordinals, typed path classes, and exact evidence +//! references. + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::sccm::{ + SccmArtifactFamily, SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmExtractionProfile, + SccmRotation, SccmTimeOrderingState, +}; + +use super::{ + SccmClientAdmittedEvidence, SccmClientEvidenceAdmissionError, SccmTaskSequencePathClass, + TASK_SEQUENCE_TEST_PROFILE_ID, TASK_SEQUENCE_TEST_VERSION, +}; + +const TASK_SEQUENCE_LOGICAL_ARTIFACT_ID: &str = "client-task-sequence-smsts"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTaskSequencePhase { + Start, + Preflight, + DiskOrImage, + SetupWindows, + InstallClient, + InstallSoftware, + PostAction, + Complete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTaskSequenceState { + InProgress, + BlockedOrDeferred, + Failed, + Succeeded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTaskSequenceClassification { + Success, + ConfirmedFailure, + BlockedOrDeferred, + InsufficientEvidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTaskSequenceConfidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTaskSequenceOrderingState { + NormalizedUtc, + Ambiguous, + OffsetMissing, + OffsetInvalid, + TimestampMissing, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequenceIdentityProof { + pub extraction_profile_id: String, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequencePathObservation { + pub artifact_id: String, + pub path_class: SccmTaskSequencePathClass, + pub rotation: SccmRotation, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequenceCoverageGap { + pub artifact_id: String, + pub coverage: SccmTaskSequenceCoverageState, + pub path_class: SccmTaskSequencePathClass, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTaskSequenceCoverageState { + Partial, + Absent, + AccessDenied, + Capped, + Skipped, + Unsupported, + ParseFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequenceNextEvidence { + pub logical_artifact_id: String, + pub path_class: SccmTaskSequencePathClass, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequenceTransaction { + pub transaction_id: String, + pub identity_proof: SccmTaskSequenceIdentityProof, + pub evidence: Vec, + pub path_sequence: Vec, + pub phase: SccmTaskSequencePhase, + pub state: SccmTaskSequenceState, + pub last_successful_phase: SccmTaskSequencePhase, + pub classification: SccmTaskSequenceClassification, + pub confidence: SccmTaskSequenceConfidence, + pub ordering_state: SccmTaskSequenceOrderingState, + pub terminal_evidence: Option, + pub next_evidence: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequenceFinding { + pub finding_id: String, + pub transaction_id: Option, + pub classification: SccmTaskSequenceClassification, + pub phase: Option, + pub confidence: SccmTaskSequenceConfidence, + pub evidence: Vec, + pub coverage_gaps: Vec, + pub next_evidence: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequenceEvidenceCitation { + pub artifact_id: String, + pub line_start: u32, + pub line_end: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTaskSequenceKeyConfidence { + None, + Candidate, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequenceSourceLocalObservation { + pub observation_id: String, + pub artifact_id: String, + pub key_confidence: SccmTaskSequenceKeyConfidence, + pub confidence: SccmTaskSequenceConfidence, + pub correlation_eligible: bool, + pub phase_hint: Option, + pub state_hint: Option, + pub evidence: Option, + pub path_class: SccmTaskSequencePathClass, + pub rotation: SccmRotation, + pub coverage: SccmCoverageState, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequenceAnalysis { + pub transactions: Vec, + pub source_local_observations: Vec, + pub findings: Vec, + pub coverage_gaps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct ExecutionIdentity { + execution_id: String, + package_id: String, + advertisement_id: String, + run_context: String, +} + +#[derive(Debug, Clone)] +struct Observation { + identity: ExecutionIdentity, + evidence: SccmEvidenceRef, + path_class: SccmTaskSequencePathClass, + rotation: SccmRotation, + phase: SccmTaskSequencePhase, + state: SccmTaskSequenceState, + terminal: bool, + ordering_state: SccmTimeOrderingState, + utc_millis: Option, +} + +#[derive(Debug, Clone)] +struct UnlinkedObservation { + evidence: SccmEvidenceRef, + path_class: SccmTaskSequencePathClass, + rotation: SccmRotation, + key_confidence: SccmTaskSequenceKeyConfidence, + phase_hint: Option, + state_hint: Option, + reason: &'static str, +} + +pub fn analyze_client_task_sequence( + admitted: &SccmClientAdmittedEvidence, +) -> Result { + let sealed = admitted.task_sequence_evidence()?; + let evidence = sealed.evidence; + let sources = sealed.sources; + let mut groups: BTreeMap> = BTreeMap::new(); + let mut rotated = Vec::new(); + let mut unlinked = Vec::new(); + + for record in evidence { + let Some(source) = sources.get(&record.reference.artifact_id) else { + continue; + }; + let Some(profile) = sealed.profiles.get(&record.reference.artifact_id) else { + unlinked.push(UnlinkedObservation { + evidence: record.reference.clone(), + path_class: source.path_class, + rotation: source.rotation.clone(), + key_confidence: SccmTaskSequenceKeyConfidence::None, + phase_hint: None, + state_hint: None, + reason: "No sealed extraction profile owns this physical source.", + }); + continue; + }; + if !is_reviewed_profile(profile) { + unlinked.push(unlinked_observation( + record, + source.path_class, + &source.rotation, + "Key-looking fields from an unrecognized source version cannot be promoted by an unverified extraction profile.", + )); + continue; + } + let Some(observation) = + extract_observation(record, source.path_class, &source.rotation, profile) + else { + unlinked.push(unlinked_observation( + record, + source.path_class, + &source.rotation, + "A path, timestamp, display name, or partial key cannot substitute for the complete record-local execution key.", + )); + continue; + }; + + if matches!(source.rotation, SccmRotation::Current) { + groups + .entry(observation.identity.clone()) + .or_default() + .push(observation); + } else { + rotated.push(observation); + } + } + + for observation in rotated { + if let Some(group) = groups.get_mut(&observation.identity) { + group.push(observation); + } else { + unlinked.push(UnlinkedObservation { + evidence: observation.evidence, + path_class: observation.path_class, + rotation: observation.rotation, + key_confidence: SccmTaskSequenceKeyConfidence::Candidate, + phase_hint: Some(observation.phase), + state_hint: Some(observation.state), + reason: "A rotated record without a current record for the same exact execution remains source-local.", + }); + } + } + + let mut transactions = groups.into_values().map(reduce_group).collect::>(); + transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_id)); + + let mut coverage_gaps = sources + .iter() + .filter(|(_, source)| { + source.coverage != SccmCoverageState::Captured || source.fragment_complete != Some(true) + }) + .map(|(artifact_id, source)| SccmTaskSequenceCoverageGap { + artifact_id: artifact_id.clone(), + coverage: task_sequence_coverage(source), + path_class: source.path_class, + }) + .collect::>(); + if coverage_gaps.is_empty() { + if let Some(coverage) = sealed + .coverage + .filter(|coverage| **coverage != SccmCoverageState::Captured) + { + coverage_gaps.push(SccmTaskSequenceCoverageGap { + artifact_id: TASK_SEQUENCE_LOGICAL_ARTIFACT_ID.to_owned(), + coverage: coverage_state(coverage), + path_class: SccmTaskSequencePathClass::Unknown, + }); + } + } + coverage_gaps.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + + unlinked.sort_by(|left, right| compare_evidence_refs(&left.evidence, &right.evidence)); + let mut source_local_observations = unlinked + .into_iter() + .map(source_local_observation) + .collect::>(); + source_local_observations.extend(sources.iter().filter_map(|(artifact_id, source)| { + let physical = source.physical_evidence.as_ref()?; + (source.coverage == SccmCoverageState::Captured && source.fragment_complete != Some(true)) + .then(|| source_local_fragment_observation(artifact_id, source, physical)) + })); + source_local_observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + + let mut findings = transactions + .iter() + .filter(|transaction| transaction.classification != SccmTaskSequenceClassification::Success) + .map(finding_for_transaction) + .collect::>(); + let uncovered_source_observations = source_local_observations + .iter() + .filter(|observation| { + !coverage_gaps + .iter() + .any(|gap| gap.artifact_id == observation.artifact_id) + }) + .collect::>(); + findings.extend( + uncovered_source_observations + .iter() + .map(|observation| finding_for_source_locals(&[*observation])), + ); + if !coverage_gaps.is_empty() { + findings.push(finding_for_coverage( + &coverage_gaps, + &source_local_observations, + )); + } + findings.sort_by(|left, right| left.finding_id.cmp(&right.finding_id)); + + Ok(SccmTaskSequenceAnalysis { + transactions, + source_local_observations, + findings, + coverage_gaps, + }) +} + +fn task_sequence_coverage( + source: &super::admission::SccmClientAdmittedTaskSequenceSource, +) -> SccmTaskSequenceCoverageState { + if source.coverage == SccmCoverageState::Captured && source.fragment_complete != Some(true) { + return SccmTaskSequenceCoverageState::Partial; + } + coverage_state(&source.coverage) +} + +fn coverage_state(coverage: &SccmCoverageState) -> SccmTaskSequenceCoverageState { + match coverage { + SccmCoverageState::Captured => SccmTaskSequenceCoverageState::Partial, + SccmCoverageState::Absent => SccmTaskSequenceCoverageState::Absent, + SccmCoverageState::AccessDenied => SccmTaskSequenceCoverageState::AccessDenied, + SccmCoverageState::Capped => SccmTaskSequenceCoverageState::Capped, + SccmCoverageState::Skipped => SccmTaskSequenceCoverageState::Skipped, + SccmCoverageState::Unsupported => SccmTaskSequenceCoverageState::Unsupported, + SccmCoverageState::ParseFailed => SccmTaskSequenceCoverageState::ParseFailed, + } +} + +fn extract_observation( + evidence: &SccmEvidence, + admitted_path_class: SccmTaskSequencePathClass, + rotation: &SccmRotation, + profile: &SccmExtractionProfile, +) -> Option { + if !is_reviewed_profile(profile) { + return None; + } + + let execution_id = capture_field(&evidence.message, "executionId")?; + let package_id = capture_field(&evidence.message, "taskSequencePackageId")?; + let advertisement_id = capture_field(&evidence.message, "advertisementId")?; + let run_context = capture_field(&evidence.message, "runContext")?; + let phase = parse_phase(capture_field(&evidence.message, "phase")?)?; + let state = parse_state(capture_field(&evidence.message, "state")?)?; + let terminal = parse_bool(capture_field(&evidence.message, "terminal")?)?; + let observed_path_class = capture_field(&evidence.message, "_SMSTSLogPath") + .map(classify_observed_path) + .unwrap_or(SccmTaskSequencePathClass::Unknown); + + if !is_uuid(execution_id) + || !is_fixed_alphanumeric(package_id, 8) + || !is_fixed_alphanumeric(advertisement_id, 8) + || !is_opaque_token(run_context) + || observed_path_class != SccmTaskSequencePathClass::Unknown + && observed_path_class != admitted_path_class + { + return None; + } + + Some(Observation { + identity: ExecutionIdentity { + execution_id: execution_id.to_ascii_lowercase(), + package_id: package_id.to_ascii_uppercase(), + advertisement_id: advertisement_id.to_ascii_uppercase(), + run_context: run_context.to_ascii_lowercase(), + }, + evidence: evidence.reference.clone(), + path_class: observed_path_class, + rotation: rotation.clone(), + phase, + state, + terminal, + ordering_state: evidence.timestamp.ordering_state.clone(), + utc_millis: evidence.timestamp.utc_millis, + }) +} + +fn unlinked_observation( + evidence: &SccmEvidence, + path_class: SccmTaskSequencePathClass, + rotation: &SccmRotation, + reason: &'static str, +) -> UnlinkedObservation { + let has_candidate_key = [ + "executionId", + "taskSequencePackageId", + "advertisementId", + "runContext", + ] + .iter() + .all(|label| capture_field(&evidence.message, label).is_some()); + UnlinkedObservation { + evidence: evidence.reference.clone(), + path_class, + rotation: rotation.clone(), + key_confidence: if has_candidate_key { + SccmTaskSequenceKeyConfidence::Candidate + } else { + SccmTaskSequenceKeyConfidence::None + }, + phase_hint: capture_field(&evidence.message, "phase").and_then(parse_phase), + state_hint: capture_field(&evidence.message, "state").and_then(parse_state), + reason, + } +} + +fn source_local_observation( + observation: UnlinkedObservation, +) -> SccmTaskSequenceSourceLocalObservation { + let citation = evidence_citation(&observation.evidence); + let observation_id = stable_opaque_id( + "cmtraceopen.task-sequence.observation.sha256.v1:", + &[ + citation.artifact_id.as_str(), + &citation.line_start.to_string(), + &citation.line_end.to_string(), + ], + ); + SccmTaskSequenceSourceLocalObservation { + observation_id, + artifact_id: citation.artifact_id.clone(), + key_confidence: observation.key_confidence, + confidence: SccmTaskSequenceConfidence::Low, + correlation_eligible: false, + phase_hint: observation.phase_hint, + state_hint: observation.state_hint, + evidence: Some(citation), + path_class: observation.path_class, + rotation: observation.rotation, + coverage: SccmCoverageState::Captured, + reason: observation.reason.to_owned(), + } +} + +fn source_local_fragment_observation( + artifact_id: &str, + source: &super::admission::SccmClientAdmittedTaskSequenceSource, + physical: &super::admission::SccmClientAdmittedTaskSequencePhysicalEvidence, +) -> SccmTaskSequenceSourceLocalObservation { + let citation = SccmTaskSequenceEvidenceCitation { + artifact_id: artifact_id.to_owned(), + line_start: physical.line_start, + line_end: physical.line_end, + }; + SccmTaskSequenceSourceLocalObservation { + observation_id: stable_opaque_id( + "cmtraceopen.task-sequence.observation.sha256.v1:", + &[ + artifact_id, + &physical.line_start.to_string(), + &physical.line_end.to_string(), + "physical-fragment", + ], + ), + artifact_id: artifact_id.to_owned(), + key_confidence: if physical.key_candidate { + SccmTaskSequenceKeyConfidence::Candidate + } else { + SccmTaskSequenceKeyConfidence::None + }, + confidence: SccmTaskSequenceConfidence::Low, + correlation_eligible: false, + phase_hint: None, + state_hint: None, + evidence: Some(citation), + path_class: source.path_class, + rotation: source.rotation.clone(), + coverage: source.coverage.clone(), + reason: + "An incomplete physical rotation fragment is not independently a logical CCM record." + .to_owned(), + } +} + +fn evidence_citation(reference: &SccmEvidenceRef) -> SccmTaskSequenceEvidenceCitation { + SccmTaskSequenceEvidenceCitation { + artifact_id: reference.artifact_id.clone(), + line_start: reference + .line_start + .expect("admission requires a physical start line"), + line_end: reference + .line_end + .expect("admission requires a physical end line"), + } +} + +fn is_reviewed_profile(profile: &SccmExtractionProfile) -> bool { + profile.profile_id == TASK_SEQUENCE_TEST_PROFILE_ID + && profile.selected_configmgr_version.as_deref() == Some(TASK_SEQUENCE_TEST_VERSION) + && profile.configmgr_version_prefixes == [TASK_SEQUENCE_TEST_VERSION] + && profile.validated_artifact_families == [SccmArtifactFamily::ClientTaskSequence] +} + +fn capture_field<'a>(message: &'a str, label: &str) -> Option<&'a str> { + let mut values = message.split_ascii_whitespace().filter_map(|token| { + let (candidate_label, value) = token.split_once('=')?; + (candidate_label.eq_ignore_ascii_case(label) && !value.is_empty()).then_some(value) + }); + let value = values.next()?; + values.next().is_none().then_some(value) +} + +fn parse_phase(value: &str) -> Option { + match value { + "start" => Some(SccmTaskSequencePhase::Start), + "preflight" => Some(SccmTaskSequencePhase::Preflight), + "diskOrImage" => Some(SccmTaskSequencePhase::DiskOrImage), + "setupWindows" => Some(SccmTaskSequencePhase::SetupWindows), + "installClient" => Some(SccmTaskSequencePhase::InstallClient), + "installSoftware" => Some(SccmTaskSequencePhase::InstallSoftware), + "postAction" => Some(SccmTaskSequencePhase::PostAction), + "complete" => Some(SccmTaskSequencePhase::Complete), + _ => None, + } +} + +fn parse_state(value: &str) -> Option { + match value { + "inProgress" => Some(SccmTaskSequenceState::InProgress), + "blockedOrDeferred" => Some(SccmTaskSequenceState::BlockedOrDeferred), + "failed" => Some(SccmTaskSequenceState::Failed), + "succeeded" => Some(SccmTaskSequenceState::Succeeded), + _ => None, + } +} + +fn parse_bool(value: &str) -> Option { + match value { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +fn classify_observed_path(value: &str) -> SccmTaskSequencePathClass { + match value { + "SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log" => SccmTaskSequencePathClass::WinPe, + "SYNTHETIC://setup/smstslog/smsts.log" => SccmTaskSequencePathClass::Setup, + "SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log" => { + SccmTaskSequencePathClass::FullOs + } + "SYNTHETIC://client/CCM/Logs/smsts.log" + | "SYNTHETIC://client/CCM/Logs/smstslog/smsts.log" + | "SYNTHETIC://client/root-a/CCM/Logs/smstslog/smsts.log" + | "SYNTHETIC://client/root-b/CCM/Logs/smstslog/smsts.log" => { + SccmTaskSequencePathClass::Client + } + _ => SccmTaskSequencePathClass::Unknown, + } +} + +fn is_uuid(value: &str) -> bool { + value.len() == 36 + && value.bytes().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) +} + +fn is_fixed_alphanumeric(value: &str, width: usize) -> bool { + value.len() == width && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +fn is_opaque_token(value: &str) -> bool { + value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) +} + +fn reduce_group(mut observations: Vec) -> SccmTaskSequenceTransaction { + let identity = observations + .first() + .expect("an execution group contains at least one observation") + .identity + .clone(); + let ordering_is_normalized = observations.iter().all(|observation| { + observation.ordering_state == SccmTimeOrderingState::NormalizedUtc + && observation.utc_millis.is_some() + }); + let timestamps_are_unique = observations + .iter() + .filter_map(|observation| observation.utc_millis) + .collect::>() + .len() + == observations.len(); + if ordering_is_normalized && timestamps_are_unique { + observations.sort_by(compare_observations); + } else { + observations.sort_by(|left, right| compare_evidence_refs(&left.evidence, &right.evidence)); + } + let phases_are_monotonic = observations + .windows(2) + .all(|pair| pair[0].phase <= pair[1].phase); + // The reviewed four-field key has no attempt discriminator or recovery + // marker. A record after any terminal record therefore cannot be called a + // retry or continuation; keep the whole execution ambiguous until a future + // profile supplies explicit record-local attempt authority. + let terminal_is_final = observations + .iter() + .enumerate() + .all(|(index, observation)| !observation.terminal || index + 1 == observations.len()); + let ordering_is_safe = ordering_is_normalized + && timestamps_are_unique + && phases_are_monotonic + && terminal_is_final; + let representative = if ordering_is_safe { + observations + .last() + .expect("an execution group contains at least one observation") + } else { + observations + .iter() + .max_by(|left, right| { + left.phase + .cmp(&right.phase) + .then_with(|| compare_evidence_refs(&left.evidence, &right.evidence)) + }) + .expect("an execution group contains at least one observation") + }; + let (classification, confidence) = classify_transaction(representative, ordering_is_safe); + let evidence = observations + .iter() + .map(|observation| observation.evidence.clone()) + .collect::>(); + let terminal_evidence = + (ordering_is_safe && representative.terminal).then(|| representative.evidence.clone()); + let ordering_state = if ordering_is_safe { + SccmTaskSequenceOrderingState::NormalizedUtc + } else if observations.len() > 1 { + SccmTaskSequenceOrderingState::Ambiguous + } else { + task_sequence_ordering_state(&representative.ordering_state) + }; + + SccmTaskSequenceTransaction { + transaction_id: stable_opaque_id( + "cmtraceopen.task-sequence.transaction.sha256.v1:", + &[ + &identity.execution_id, + &identity.package_id, + &identity.advertisement_id, + &identity.run_context, + ], + ), + identity_proof: SccmTaskSequenceIdentityProof { + extraction_profile_id: TASK_SEQUENCE_TEST_PROFILE_ID.to_owned(), + evidence: evidence.clone(), + }, + evidence, + path_sequence: observations + .iter() + .map(|observation| SccmTaskSequencePathObservation { + artifact_id: observation.evidence.artifact_id.clone(), + path_class: observation.path_class, + rotation: observation.rotation.clone(), + }) + .collect(), + phase: representative.phase, + state: representative.state, + last_successful_phase: last_successful_phase(representative), + classification, + confidence, + ordering_state, + terminal_evidence, + next_evidence: next_evidence(representative, classification), + } +} + +fn task_sequence_ordering_state( + ordering_state: &SccmTimeOrderingState, +) -> SccmTaskSequenceOrderingState { + match ordering_state { + SccmTimeOrderingState::NormalizedUtc => SccmTaskSequenceOrderingState::NormalizedUtc, + SccmTimeOrderingState::OffsetMissing => SccmTaskSequenceOrderingState::OffsetMissing, + SccmTimeOrderingState::OffsetInvalid => SccmTaskSequenceOrderingState::OffsetInvalid, + SccmTimeOrderingState::TimestampMissing => SccmTaskSequenceOrderingState::TimestampMissing, + } +} + +fn compare_observations(left: &Observation, right: &Observation) -> Ordering { + left.utc_millis + .cmp(&right.utc_millis) + .then_with(|| compare_evidence_refs(&left.evidence, &right.evidence)) +} + +fn compare_evidence_refs(left: &SccmEvidenceRef, right: &SccmEvidenceRef) -> Ordering { + ( + left.artifact_id.as_str(), + left.line_start, + left.line_end, + left.entry_id.as_str(), + ) + .cmp(&( + right.artifact_id.as_str(), + right.line_start, + right.line_end, + right.entry_id.as_str(), + )) +} + +fn classify_transaction( + final_observation: &Observation, + ordering_is_safe: bool, +) -> (SccmTaskSequenceClassification, SccmTaskSequenceConfidence) { + if !ordering_is_safe { + return ( + SccmTaskSequenceClassification::InsufficientEvidence, + SccmTaskSequenceConfidence::Low, + ); + } + match ( + final_observation.phase, + final_observation.state, + final_observation.terminal, + ) { + (_, SccmTaskSequenceState::Failed, true) => ( + SccmTaskSequenceClassification::ConfirmedFailure, + SccmTaskSequenceConfidence::High, + ), + (SccmTaskSequencePhase::Complete, SccmTaskSequenceState::Succeeded, true) => ( + SccmTaskSequenceClassification::Success, + SccmTaskSequenceConfidence::High, + ), + (_, SccmTaskSequenceState::BlockedOrDeferred, false) => ( + SccmTaskSequenceClassification::BlockedOrDeferred, + SccmTaskSequenceConfidence::Medium, + ), + _ => ( + SccmTaskSequenceClassification::InsufficientEvidence, + SccmTaskSequenceConfidence::Medium, + ), + } +} + +fn last_successful_phase(observation: &Observation) -> SccmTaskSequencePhase { + if observation.state == SccmTaskSequenceState::Succeeded { + return observation.phase; + } + match observation.phase { + SccmTaskSequencePhase::Start | SccmTaskSequencePhase::Preflight => { + SccmTaskSequencePhase::Start + } + SccmTaskSequencePhase::DiskOrImage => SccmTaskSequencePhase::Preflight, + SccmTaskSequencePhase::SetupWindows => SccmTaskSequencePhase::DiskOrImage, + SccmTaskSequencePhase::InstallClient => SccmTaskSequencePhase::SetupWindows, + SccmTaskSequencePhase::InstallSoftware => SccmTaskSequencePhase::InstallClient, + SccmTaskSequencePhase::PostAction => SccmTaskSequencePhase::InstallSoftware, + SccmTaskSequencePhase::Complete => SccmTaskSequencePhase::PostAction, + } +} + +fn next_evidence( + observation: &Observation, + classification: SccmTaskSequenceClassification, +) -> Option { + if matches!( + classification, + SccmTaskSequenceClassification::Success | SccmTaskSequenceClassification::ConfirmedFailure + ) { + return None; + } + let (path_class, reason) = match observation.phase { + SccmTaskSequencePhase::Start | SccmTaskSequencePhase::Preflight + if observation.path_class == SccmTaskSequencePathClass::Client => + { + ( + SccmTaskSequencePathClass::Client, + "Collect the next complete client Task Sequence record.", + ) + } + SccmTaskSequencePhase::Start | SccmTaskSequencePhase::Preflight => ( + SccmTaskSequencePathClass::Setup, + "Collect the post-format Task Sequence continuation.", + ), + SccmTaskSequencePhase::DiskOrImage => ( + SccmTaskSequencePathClass::FullOs, + "Collect the relocated pre-client Task Sequence continuation.", + ), + SccmTaskSequencePhase::SetupWindows => ( + SccmTaskSequencePathClass::Client, + "Collect the post-client Task Sequence continuation.", + ), + _ => ( + SccmTaskSequencePathClass::Client, + "Collect the next complete client Task Sequence record.", + ), + }; + Some(SccmTaskSequenceNextEvidence { + logical_artifact_id: TASK_SEQUENCE_LOGICAL_ARTIFACT_ID.to_owned(), + path_class, + reason: reason.to_owned(), + }) +} + +fn finding_for_transaction(transaction: &SccmTaskSequenceTransaction) -> SccmTaskSequenceFinding { + SccmTaskSequenceFinding { + finding_id: stable_opaque_id( + "cmtraceopen.task-sequence.finding.sha256.v1:", + &[&transaction.transaction_id, "transaction"], + ), + transaction_id: Some(transaction.transaction_id.clone()), + classification: transaction.classification, + phase: Some(transaction.phase), + confidence: transaction.confidence, + evidence: transaction.evidence.iter().map(evidence_citation).collect(), + coverage_gaps: Vec::new(), + next_evidence: None, + } +} + +fn finding_for_source_locals( + observations: &[&SccmTaskSequenceSourceLocalObservation], +) -> SccmTaskSequenceFinding { + let mut identity_parts = vec!["source-local"]; + identity_parts.extend( + observations + .iter() + .map(|observation| observation.observation_id.as_str()), + ); + let evidence = observations + .iter() + .filter_map(|observation| observation.evidence.clone()) + .collect::>(); + let path_class = observations + .first() + .map(|observation| observation.path_class) + .unwrap_or(SccmTaskSequencePathClass::Unknown); + SccmTaskSequenceFinding { + finding_id: stable_opaque_id( + "cmtraceopen.task-sequence.finding.sha256.v1:", + &identity_parts, + ), + transaction_id: None, + classification: SccmTaskSequenceClassification::InsufficientEvidence, + phase: None, + confidence: SccmTaskSequenceConfidence::Low, + evidence, + coverage_gaps: Vec::new(), + next_evidence: observations + .iter() + .any(|observation| { + observation.key_confidence == SccmTaskSequenceKeyConfidence::Candidate + }) + .then(|| SccmTaskSequenceNextEvidence { + logical_artifact_id: TASK_SEQUENCE_LOGICAL_ARTIFACT_ID.to_owned(), + path_class, + reason: "Add or apply a reviewed extraction profile before correlation.".to_owned(), + }), + } +} + +fn finding_for_coverage( + coverage_gaps: &[SccmTaskSequenceCoverageGap], + observations: &[SccmTaskSequenceSourceLocalObservation], +) -> SccmTaskSequenceFinding { + let path_class = coverage_gaps + .first() + .map(|gap| gap.path_class) + .unwrap_or(SccmTaskSequencePathClass::Unknown); + let mut identity_parts = vec!["coverage"]; + identity_parts.extend(coverage_gaps.iter().map(|gap| gap.artifact_id.as_str())); + let evidence = observations + .iter() + .filter(|observation| { + coverage_gaps + .iter() + .any(|gap| gap.artifact_id == observation.artifact_id) + }) + .filter_map(|observation| observation.evidence.clone()) + .collect::>(); + let finding_coverage_gaps = if evidence.is_empty() { + coverage_gaps.to_vec() + } else { + Vec::new() + }; + SccmTaskSequenceFinding { + finding_id: stable_opaque_id( + "cmtraceopen.task-sequence.finding.sha256.v1:", + &identity_parts, + ), + transaction_id: None, + classification: SccmTaskSequenceClassification::InsufficientEvidence, + phase: None, + confidence: SccmTaskSequenceConfidence::Low, + evidence, + coverage_gaps: finding_coverage_gaps, + next_evidence: Some(SccmTaskSequenceNextEvidence { + logical_artifact_id: TASK_SEQUENCE_LOGICAL_ARTIFACT_ID.to_owned(), + path_class, + reason: "Collect a complete Task Sequence logical record from the active path." + .to_owned(), + }), + } +} + +fn stable_opaque_id(prefix: &str, parts: &[&str]) -> String { + const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + + let mut digest = Sha256::new(); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part.as_bytes()); + } + let digest = digest.finalize(); + let mut encoded = String::with_capacity(prefix.len() + digest.len() * 2); + encoded.push_str(prefix); + for byte in digest { + encoded.push(char::from(LOWER_HEX[usize::from(byte >> 4)])); + encoded.push(char::from(LOWER_HEX[usize::from(byte & 0x0f)])); + } + encoded +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/updates.rs b/crates/cmtraceopen-parser/src/sccm/client/updates.rs new file mode 100644 index 000000000..3a10d0cc9 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/updates.rs @@ -0,0 +1,1129 @@ +//! Conservative SCCM client software-update transaction analysis. +//! +//! The analyzer accepts only evidence produced by the sealed client admission +//! boundary. It never reads files, consumes another reducer, or infers a +//! server-side SUP cause. + +use std::collections::BTreeMap; + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::sccm::{ + SccmConfidence, SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFindingClass, + SccmKeyConfidence, SccmTimeOrderingState, SccmTimestamp, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, +}; + +use super::{SccmClientAdmittedEvidence, SccmClientEvidenceAdmissionError}; + +pub const SCCM_CLIENT_UPDATES_ANALYSIS_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientUpdatePhase { + Scan, + Evaluate, + LocateSup, + Download, + MaintenanceWindow, + Install, + Reboot, + Report, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientUpdateState { + Succeeded, + Failed, + BlockedOrDeferred, + Incomplete, + Contradictory, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientUpdateClassification { + Success, + ConfirmedFailure, + BlockedOrDeferred, + InsufficientEvidence, + Symptom, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateKey { + pub update_id: String, + pub ci_id: String, + pub content_id: Option, + pub update_job_id: Option, + pub client_handle: Option, + pub site_code: Option, + pub sup_host_handle: Option, + pub confidence: SccmKeyConfidence, + pub extraction_profile_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateArtifactRequest { + pub logical_artifact_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateTransaction { + pub transaction_id: String, + pub key: SccmClientUpdateKey, + pub phase: SccmClientUpdatePhase, + pub state: SccmClientUpdateState, + pub last_successful_phase: Option, + pub classification: SccmClientUpdateClassification, + pub confidence: SccmConfidence, + pub confidence_ceiling: SccmConfidence, + pub coverage_gap_artifact_ids: Vec, + pub next_artifact: Option, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateFinding { + pub finding_id: String, + pub subject_id: String, + pub class: SccmFindingClass, + pub phase: Option, + pub last_successful_phase: Option, + pub confidence: SccmConfidence, + pub confidence_ceiling: SccmConfidence, + pub next_artifact: Option, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateObservation { + pub observation_id: String, + pub reason: String, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateCoverage { + pub logical_artifact_id: String, + pub state: SccmClientUpdateCoverageState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmClientUpdateCoverageState { + Captured, + Partial, + Absent, + Skipped, + Unsupported, + ParseFailed, + Capped, + AccessDenied, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateExtractionProfile { + pub selection_state: String, + pub profile_id: String, + pub key_confidence_ceiling: SccmKeyConfidence, + pub validated_artifact_families: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateCorrelationHandoff { + pub issue: String, + pub server_prerequisite_issue: String, + pub performed: bool, + pub time_only_eligible: bool, + pub topology_compatibility_evaluated: bool, + pub server_cause_claimed: bool, + pub native_acceptance_claimed: bool, + pub bundle_capture_host_used_as_sup_evidence: bool, + pub counterpart_ready_key_kinds: Vec, + pub emitted_counterpart_ready_fact: bool, + pub counterpart_ready_facts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateCounterpartEvidence { + pub artifact_id: String, + pub start_line: u32, + pub end_line: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateTimestampProvenance { + pub normalized_utc: String, + pub utc_millis: i64, + pub offset_minutes: i32, + pub ordering_state: SccmTimeOrderingState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdateCounterpartReadyFact { + pub update_id: String, + pub ci_id: String, + pub content_id: String, + pub update_job_id: String, + pub client_handle: String, + pub site_code: String, + pub sup_host_handle: String, + pub key_confidence: SccmKeyConfidence, + pub correlation_eligible: bool, + pub time_only_eligible: bool, + pub phase: SccmClientUpdatePhase, + pub extraction_profile_id: String, + pub timestamp_provenance: SccmClientUpdateTimestampProvenance, + pub evidence: SccmClientUpdateCounterpartEvidence, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdatesAnalysis { + pub schema_version: u32, + pub transactions: Vec, + pub observations: Vec, + pub findings: Vec, + pub coverage: Vec, + pub extraction_profile: SccmClientUpdateExtractionProfile, + pub correlation_handoff: SccmClientUpdateCorrelationHandoff, + pub prohibited_claims: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PhaseDisposition { + Succeeded, + Failed, + Deferred, + Contradictory, +} + +#[derive(Debug, Clone)] +struct UpdateFact { + key: SccmClientUpdateKey, + phase: SccmClientUpdatePhase, + disposition: PhaseDisposition, + evidence: SccmEvidenceRef, + timestamp: SccmTimestamp, + location_services_source: bool, +} + +type UpdateSubjectKey = ( + String, + String, + Option, + Option, + Option, + Option, + Option, +); + +/// Reduces sealed, intake-bound client evidence into update transactions. +pub fn analyze_client_updates( + admitted: &SccmClientAdmittedEvidence, +) -> Result { + let declared_gap_phases = declared_gap_phases(admitted)?; + let mut facts_by_key = BTreeMap::>::new(); + let mut observations = Vec::new(); + for evidence in admitted.evidence()? { + if let Some(fact) = update_fact(admitted, evidence)? { + facts_by_key + .entry(subject_key(&fact.key)) + .or_default() + .push(fact); + } else if is_update_source(evidence.component.as_deref()) { + observations.push(SccmClientUpdateObservation { + observation_id: format!("updates:source-local:{}", evidence.evidence_id), + reason: "Update-related source evidence was retained locally but did not satisfy an exact phase/key grammar.".to_owned(), + evidence: vec![evidence.reference.clone()], + }); + } + } + let supplemental_present = + ["CBS.log", "ReportingEvents.log"] + .into_iter() + .try_fold(false, |present, basename| { + admitted + .source_coverage_for_basename(basename) + .map(|coverage| { + present + || coverage + .is_some_and(|coverage| *coverage == SccmCoverageState::Captured) + }) + })?; + if supplemental_present { + observations.push(SccmClientUpdateObservation { + observation_id: "updates:source-local:windows-update-supplemental".to_owned(), + reason: "Windows servicing supplemental evidence remains source-local and cannot override the SCCM update transaction.".to_owned(), + evidence: Vec::new(), + }); + } + observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + let client_updates_unavailable = match admitted.require_captured_source("client-updates") { + Ok(()) => false, + Err( + SccmClientEvidenceAdmissionError::SourceCoverageUnavailable + | SccmClientEvidenceAdmissionError::UnknownSourceGroup, + ) => true, + Err(error) => return Err(error), + }; + if client_updates_unavailable && observations.is_empty() { + observations.push(SccmClientUpdateObservation { + observation_id: "updates:source-local:coverage".to_owned(), + reason: "The client update source group was declared but was not admitted as complete captured evidence.".to_owned(), + evidence: Vec::new(), + }); + } + + let mut transactions = Vec::new(); + let mut findings = Vec::new(); + let mut counterpart_ready_facts = Vec::new(); + for (_, mut facts) in facts_by_key { + facts.sort_by(|left, right| { + left.phase + .cmp(&right.phase) + .then_with(|| left.timestamp.utc_millis.cmp(&right.timestamp.utc_millis)) + .then_with(|| left.evidence.artifact_id.cmp(&right.evidence.artifact_id)) + .then_with(|| left.evidence.line_start.cmp(&right.evidence.line_start)) + }); + let mut effective_by_phase = BTreeMap::::new(); + for phase in facts.iter().map(|fact| fact.phase) { + if effective_by_phase.contains_key(&phase) { + continue; + } + let phase_facts = facts + .iter() + .filter(|fact| fact.phase == phase) + .collect::>(); + effective_by_phase.insert(phase, effective_phase_fact(&phase_facts)); + } + let Some(last) = effective_by_phase.values().next_back() else { + continue; + }; + if let Some(counterpart) = facts.iter().find_map(counterpart_ready_fact) { + counterpart_ready_facts.push(counterpart); + } + let failed = effective_by_phase + .values() + .filter(|fact| fact.disposition == PhaseDisposition::Failed) + .min_by_key(|fact| fact.phase); + let deferred = effective_by_phase + .values() + .filter(|fact| fact.disposition == PhaseDisposition::Deferred) + .max_by_key(|fact| fact.phase); + let contradictory = effective_by_phase + .values() + .filter(|fact| fact.disposition == PhaseDisposition::Contradictory) + .min_by_key(|fact| fact.phase); + let decisive = failed.or(deferred).or(contradictory).unwrap_or(last); + let incomplete_phase = (failed.is_none() && deferred.is_none() && contradictory.is_none()) + .then(|| { + declared_gap_phases + .iter() + .copied() + .find(|phase| *phase > last.phase) + }) + .flatten(); + let output_phase = incomplete_phase.unwrap_or(decisive.phase); + let state = if failed.is_some() { + SccmClientUpdateState::Failed + } else if deferred.is_some() { + SccmClientUpdateState::BlockedOrDeferred + } else if contradictory.is_some() { + SccmClientUpdateState::Contradictory + } else if incomplete_phase.is_some() { + SccmClientUpdateState::Incomplete + } else { + SccmClientUpdateState::Succeeded + }; + let classification = if failed.is_some() { + SccmClientUpdateClassification::Symptom + } else if deferred.is_some() { + SccmClientUpdateClassification::BlockedOrDeferred + } else if contradictory.is_some() || incomplete_phase.is_some() { + SccmClientUpdateClassification::InsufficientEvidence + } else { + SccmClientUpdateClassification::Success + }; + let decisive_is_success = failed.is_none() && deferred.is_none() && contradictory.is_none(); + let last_successful_phase = effective_by_phase + .values() + .filter(|fact| { + fact.disposition == PhaseDisposition::Succeeded + && (fact.phase < decisive.phase + || (decisive_is_success && fact.phase == decisive.phase)) + }) + .map(|fact| fact.phase) + .max(); + let mut evidence = facts + .iter() + .filter(|fact| fact.phase <= decisive.phase) + .map(|fact| fact.evidence.clone()) + .collect::>(); + evidence.sort_by(|left, right| { + 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)) + }); + evidence.dedup(); + let next_artifact = + if deferred.is_some() || contradictory.is_some() || incomplete_phase.is_some() { + Some(request_for(output_phase)) + } else { + None + }; + let coverage_gap_artifact_ids = next_artifact + .as_ref() + .map(|request| vec![request.logical_artifact_id.clone()]) + .unwrap_or_default(); + let subject_discriminator = subject_discriminator(&decisive.key); + let transaction_id = format!( + "updates:update:{}:{}:{subject_discriminator}", + decisive.key.update_id, decisive.key.ci_id + ); + let transaction = SccmClientUpdateTransaction { + transaction_id: transaction_id.clone(), + key: decisive.key.clone(), + phase: output_phase, + state, + last_successful_phase, + classification, + confidence: SccmConfidence::Low, + confidence_ceiling: SccmConfidence::Low, + coverage_gap_artifact_ids, + next_artifact: next_artifact.clone(), + evidence: evidence.clone(), + }; + if failed.is_some() + || deferred.is_some() + || contradictory.is_some() + || incomplete_phase.is_some() + { + findings.push(SccmClientUpdateFinding { + finding_id: format!( + "finding:updates:{}:{}:{}:{}-{}", + decisive.key.update_id, + decisive.key.ci_id, + subject_discriminator, + phase_name(output_phase), + if failed.is_some() { + "failure" + } else if deferred.is_some() { + "deferred" + } else if contradictory.is_some() { + "contradictory" + } else { + "incomplete" + } + ), + subject_id: transaction_id, + class: if failed.is_some() { + SccmFindingClass::Symptom + } else if deferred.is_some() { + SccmFindingClass::BlockedOrDeferred + } else { + SccmFindingClass::InsufficientEvidence + }, + phase: Some(output_phase), + last_successful_phase, + confidence: SccmConfidence::Low, + confidence_ceiling: SccmConfidence::Low, + next_artifact, + evidence, + }); + } + transactions.push(transaction); + } + + if transactions.is_empty() && (!observations.is_empty() || client_updates_unavailable) { + let evidence = observations + .iter() + .flat_map(|observation| observation.evidence.clone()) + .collect::>(); + findings.push(SccmClientUpdateFinding { + finding_id: "finding:updates:source-local".to_owned(), + subject_id: "updates:source-local".to_owned(), + class: SccmFindingClass::InsufficientEvidence, + phase: None, + last_successful_phase: None, + confidence: SccmConfidence::Low, + confidence_ceiling: SccmConfidence::Low, + next_artifact: Some(request_for(SccmClientUpdatePhase::Install)), + evidence, + }); + } + if supplemental_present { + findings.push(SccmClientUpdateFinding { + finding_id: "finding:updates:supplemental-source-local".to_owned(), + subject_id: "updates:source-local:windows-update-supplemental".to_owned(), + class: SccmFindingClass::Symptom, + phase: transactions.first().map(|transaction| transaction.phase), + last_successful_phase: None, + confidence: SccmConfidence::Low, + confidence_ceiling: SccmConfidence::Low, + next_artifact: Some(SccmClientUpdateArtifactRequest { + logical_artifact_id: "client-windows-update-supplemental".to_owned(), + reason: "Collect the smallest bounded client-windows-update-supplemental continuation for this exact update subject.".to_owned(), + }), + evidence: Vec::new(), + }); + } + + counterpart_ready_facts.sort_by(|left, right| { + left.update_id + .cmp(&right.update_id) + .then_with(|| left.ci_id.cmp(&right.ci_id)) + .then_with(|| left.evidence.artifact_id.cmp(&right.evidence.artifact_id)) + .then_with(|| left.evidence.start_line.cmp(&right.evidence.start_line)) + }); + counterpart_ready_facts.dedup(); + let emitted_counterpart_ready_fact = !counterpart_ready_facts.is_empty(); + let coverage = update_coverage(admitted)?; + + Ok(SccmClientUpdatesAnalysis { + schema_version: SCCM_CLIENT_UPDATES_ANALYSIS_SCHEMA_VERSION, + transactions, + observations, + findings, + coverage, + extraction_profile: SccmClientUpdateExtractionProfile { + selection_state: "experimental".to_owned(), + profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + key_confidence_ceiling: SccmKeyConfidence::Low, + validated_artifact_families: Vec::new(), + }, + correlation_handoff: SccmClientUpdateCorrelationHandoff { + issue: "#333".to_owned(), + server_prerequisite_issue: "#330".to_owned(), + performed: false, + time_only_eligible: false, + topology_compatibility_evaluated: false, + server_cause_claimed: false, + native_acceptance_claimed: false, + bundle_capture_host_used_as_sup_evidence: false, + counterpart_ready_key_kinds: vec![ + "updateId".to_owned(), + "ciId".to_owned(), + "contentId".to_owned(), + "updateJobId".to_owned(), + "clientSafeHandle".to_owned(), + "siteCode".to_owned(), + "supHostHandle".to_owned(), + ], + emitted_counterpart_ready_fact, + counterpart_ready_facts, + }, + prohibited_claims: vec![ + "SUP or server root cause".to_owned(), + "time-only cross-artifact causality".to_owned(), + "policy reducer dependency".to_owned(), + "native Windows acceptance".to_owned(), + ], + }) +} + +fn effective_phase_fact(phase_facts: &[&UpdateFact]) -> UpdateFact { + let has_noncomparable = phase_facts.iter().any(|fact| { + fact.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + || fact.timestamp.utc_millis.is_none() + }); + let latest = if has_noncomparable { + phase_facts.to_vec() + } else { + let latest_timestamp = phase_facts + .iter() + .filter_map(|fact| fact.timestamp.utc_millis) + .max(); + phase_facts + .iter() + .copied() + .filter(|fact| fact.timestamp.utc_millis == latest_timestamp) + .collect::>() + }; + let first_disposition = latest[0].disposition; + let has_conflict = latest + .iter() + .any(|fact| fact.disposition != first_disposition); + let mut effective = (*latest + .iter() + .max_by(|left, right| { + left.evidence + .artifact_id + .cmp(&right.evidence.artifact_id) + .then_with(|| left.evidence.line_start.cmp(&right.evidence.line_start)) + }) + .expect("phase has at least one fact")) + .clone(); + if has_conflict { + effective.disposition = PhaseDisposition::Contradictory; + } + effective +} + +fn update_coverage( + admitted: &SccmClientAdmittedEvidence, +) -> Result, SccmClientEvidenceAdmissionError> { + let groups: [(&str, &[&str]); 7] = [ + ( + "client-content", + &["DataTransferService.log", "ContentTransferManager.log"], + ), + ("client-location-services-shared", &["LocationServices.log"]), + ("client-maintenance-window", &["ServiceWindowManager.log"]), + ("client-policy-state", &["StateMessage.log"]), + ("client-reboot", &["RebootCoordinator.log"]), + ( + "client-updates", + &[ + "ScanAgent.log", + "WUAHandler.log", + "UpdatesDeployment.log", + "UpdatesHandler.log", + "UpdatesStore.log", + ], + ), + ( + "client-windows-update-supplemental", + &["CBS.log", "ReportingEvents.log"], + ), + ]; + let mut coverage = Vec::new(); + for (logical_artifact_id, basenames) in groups { + let mut declared = false; + for basename in basenames { + declared |= admitted.source_coverage_for_basename(basename)?.is_some(); + } + if declared { + if let Some(state) = admitted.source_coverage(logical_artifact_id)? { + let all_complete = basenames.iter().try_fold(true, |complete, basename| { + if admitted.source_coverage_for_basename(basename)?.is_some() { + admitted + .source_basename_is_complete(basename) + .map(|basename_complete| complete && basename_complete) + } else { + Ok(complete) + } + })?; + coverage.push(SccmClientUpdateCoverage { + logical_artifact_id: logical_artifact_id.to_owned(), + state: if *state == SccmCoverageState::Captured && !all_complete { + SccmClientUpdateCoverageState::Partial + } else { + update_coverage_state(state) + }, + }); + } + } + } + Ok(coverage) +} + +fn is_update_source(component: Option<&str>) -> bool { + component.is_some_and(|component| { + source_is( + component, + &[ + "ScanAgent", + "WUAHandler", + "LocationServices", + "DataTransferService", + "ContentTransferManager", + "ServiceWindowManager", + "UpdatesDeployment", + "UpdatesHandler", + "UpdatesStore", + "RebootCoordinator", + "StateMessage", + "CBS", + ], + ) + }) +} + +fn declared_gap_phases( + admitted: &SccmClientAdmittedEvidence, +) -> Result, SccmClientEvidenceAdmissionError> { + let mut phases = Vec::new(); + for (basename, phase) in [ + ("ScanAgent.log", SccmClientUpdatePhase::Scan), + ("WUAHandler.log", SccmClientUpdatePhase::Evaluate), + ("LocationServices.log", SccmClientUpdatePhase::LocateSup), + ("DataTransferService.log", SccmClientUpdatePhase::Download), + ( + "ContentTransferManager.log", + SccmClientUpdatePhase::Download, + ), + ( + "ServiceWindowManager.log", + SccmClientUpdatePhase::MaintenanceWindow, + ), + ("UpdatesHandler.log", SccmClientUpdatePhase::Install), + ("RebootCoordinator.log", SccmClientUpdatePhase::Reboot), + ("StateMessage.log", SccmClientUpdatePhase::Report), + ] { + if admitted.source_coverage_for_basename(basename)?.is_some() + && !admitted.source_basename_is_complete(basename)? + && !phases.contains(&phase) + { + phases.push(phase); + } + } + Ok(phases) +} + +fn update_fact( + admitted: &SccmClientAdmittedEvidence, + evidence: &SccmEvidence, +) -> Result, SccmClientEvidenceAdmissionError> { + let Some(update_id) = + message_field(&evidence.message, "UpdateId").and_then(normalize_update_id) + else { + return Ok(None); + }; + let Some(ci_id) = message_field(&evidence.message, "CIId").and_then(safe_value) else { + return Ok(None); + }; + let Some((phase, disposition)) = phase_disposition(evidence) else { + return Ok(None); + }; + let Some(sealed_basename) = + admitted.source_basename_for_artifact(&evidence.reference.artifact_id)? + else { + return Ok(None); + }; + let Some(component) = evidence.component.as_deref() else { + return Ok(None); + }; + if !source_basename_matches(component, sealed_basename) { + return Ok(None); + } + let location_services_group_admitted = admitted + .require_captured_source("client-location-services-shared") + .is_ok(); + Ok(Some(UpdateFact { + key: SccmClientUpdateKey { + update_id, + ci_id, + content_id: optional_safe_field(&evidence.message, "ContentId"), + update_job_id: optional_safe_field(&evidence.message, "UpdateJobId"), + client_handle: optional_safe_handle(&evidence.message, "ClientHandle", "safe:client:"), + site_code: optional_safe_field(&evidence.message, "SiteCode"), + sup_host_handle: optional_safe_handle(&evidence.message, "SupHostHandle", "safe:sup:"), + confidence: SccmKeyConfidence::Low, + extraction_profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + }, + phase, + disposition, + evidence: evidence.reference.clone(), + timestamp: evidence.timestamp.clone(), + location_services_source: location_services_group_admitted + && sealed_basename == "LocationServices.log" + && component.eq_ignore_ascii_case("LocationServices"), + })) +} + +fn source_basename_matches(component: &str, basename: &str) -> bool { + [ + ("ScanAgent", "ScanAgent.log"), + ("WUAHandler", "WUAHandler.log"), + ("LocationServices", "LocationServices.log"), + ("DataTransferService", "DataTransferService.log"), + ("ContentTransferManager", "ContentTransferManager.log"), + ("ServiceWindowManager", "ServiceWindowManager.log"), + ("UpdatesDeployment", "UpdatesDeployment.log"), + ("UpdatesHandler", "UpdatesHandler.log"), + ("UpdatesStore", "UpdatesStore.log"), + ("RebootCoordinator", "RebootCoordinator.log"), + ("StateMessage", "StateMessage.log"), + ] + .into_iter() + .any(|(expected_component, expected_basename)| { + component.eq_ignore_ascii_case(expected_component) && basename == expected_basename + }) +} + +fn counterpart_ready_fact(fact: &UpdateFact) -> Option { + if !fact.location_services_source + || fact.phase != SccmClientUpdatePhase::LocateSup + || fact.disposition != PhaseDisposition::Succeeded + || fact.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + { + return None; + } + let utc_millis = fact.timestamp.utc_millis?; + let offset_minutes = fact.timestamp.offset_minutes?; + let normalized_utc = DateTime::::from_timestamp_millis(utc_millis)? + .to_rfc3339_opts(SecondsFormat::Millis, true); + Some(SccmClientUpdateCounterpartReadyFact { + update_id: fact.key.update_id.clone(), + ci_id: fact.key.ci_id.clone(), + content_id: fact.key.content_id.clone()?, + update_job_id: fact.key.update_job_id.clone()?, + client_handle: fact.key.client_handle.clone()?, + site_code: fact.key.site_code.clone()?, + sup_host_handle: fact.key.sup_host_handle.clone()?, + key_confidence: fact.key.confidence.clone(), + correlation_eligible: false, + time_only_eligible: false, + phase: SccmClientUpdatePhase::LocateSup, + extraction_profile_id: fact.key.extraction_profile_id.clone(), + timestamp_provenance: SccmClientUpdateTimestampProvenance { + normalized_utc, + utc_millis, + offset_minutes, + ordering_state: SccmTimeOrderingState::NormalizedUtc, + }, + evidence: SccmClientUpdateCounterpartEvidence { + artifact_id: fact.evidence.artifact_id.clone(), + start_line: fact.evidence.line_start?, + end_line: fact.evidence.line_end?, + }, + }) +} + +fn phase_disposition(evidence: &SccmEvidence) -> Option<(SccmClientUpdatePhase, PhaseDisposition)> { + let component = evidence.component.as_deref()?; + let message = evidence.message.to_ascii_lowercase(); + let fact = if source_is(component, &["ScanAgent"]) + && (message.contains("scanresult=failed") + || message.contains("scan terminal failure") + || message.contains("scan failed")) + { + (SccmClientUpdatePhase::Scan, PhaseDisposition::Failed) + } else if source_is(component, &["ScanAgent"]) + && (message.contains("scanresult=success") || message.contains("scan succeeded")) + { + (SccmClientUpdatePhase::Scan, PhaseDisposition::Succeeded) + } else if source_is( + component, + &[ + "ScanAgent", + "WUAHandler", + "UpdatesDeployment", + "UpdatesStore", + ], + ) && message.contains("evaluate terminal-looking") + { + ( + SccmClientUpdatePhase::Evaluate, + PhaseDisposition::Contradictory, + ) + } else if source_is( + component, + &[ + "ScanAgent", + "WUAHandler", + "UpdatesDeployment", + "UpdatesStore", + ], + ) && message.contains("evaluate terminal failure") + { + (SccmClientUpdatePhase::Evaluate, PhaseDisposition::Failed) + } else if source_is( + component, + &[ + "ScanAgent", + "WUAHandler", + "UpdatesDeployment", + "UpdatesStore", + ], + ) && (message.contains("evaluate applicable") + || message.contains("evaluate succeeded")) + { + (SccmClientUpdatePhase::Evaluate, PhaseDisposition::Succeeded) + } else if source_is(component, &["LocationServices", "UpdatesDeployment"]) + && message.contains("locatesup selected") + { + ( + SccmClientUpdatePhase::LocateSup, + PhaseDisposition::Succeeded, + ) + } else if source_is( + component, + &[ + "DataTransferService", + "ContentTransferManager", + "UpdatesDeployment", + ], + ) && message.contains("download terminal failure") + { + (SccmClientUpdatePhase::Download, PhaseDisposition::Failed) + } else if source_is( + component, + &[ + "DataTransferService", + "ContentTransferManager", + "UpdatesDeployment", + ], + ) && message.contains("download succeeded") + { + (SccmClientUpdatePhase::Download, PhaseDisposition::Succeeded) + } else if source_is( + component, + &[ + "ServiceWindowManager", + "UpdatesDeployment", + "UpdatesHandler", + "UpdatesStore", + ], + ) && message.contains("maintenancewindow deferred") + { + ( + SccmClientUpdatePhase::MaintenanceWindow, + PhaseDisposition::Deferred, + ) + } else if source_is( + component, + &[ + "ServiceWindowManager", + "UpdatesDeployment", + "UpdatesHandler", + "UpdatesStore", + ], + ) && message.contains("maintenancewindow open") + { + ( + SccmClientUpdatePhase::MaintenanceWindow, + PhaseDisposition::Succeeded, + ) + } else if source_is(component, &["UpdatesHandler", "UpdatesDeployment"]) + && message.contains("install terminal failure") + { + (SccmClientUpdatePhase::Install, PhaseDisposition::Failed) + } else if source_is(component, &["UpdatesHandler", "UpdatesDeployment"]) + && message.contains("install succeeded") + { + (SccmClientUpdatePhase::Install, PhaseDisposition::Succeeded) + } else if source_is(component, &["RebootCoordinator", "UpdatesDeployment"]) + && message.contains("reboot pending") + { + (SccmClientUpdatePhase::Reboot, PhaseDisposition::Deferred) + } else if source_is(component, &["RebootCoordinator", "UpdatesDeployment"]) + && message.contains("reboot complete") + { + (SccmClientUpdatePhase::Reboot, PhaseDisposition::Succeeded) + } else if source_is(component, &["StateMessage", "UpdatesHandler"]) + && message.contains("report terminal failure") + { + (SccmClientUpdatePhase::Report, PhaseDisposition::Failed) + } else if source_is(component, &["StateMessage", "UpdatesHandler"]) + && message.contains("report succeeded") + { + (SccmClientUpdatePhase::Report, PhaseDisposition::Succeeded) + } else { + return None; + }; + Some(fact) +} + +fn source_is(component: &str, accepted: &[&str]) -> bool { + accepted + .iter() + .any(|candidate| component.eq_ignore_ascii_case(candidate)) +} + +fn message_field<'a>(message: &'a str, label: &str) -> Option<&'a str> { + message.split_ascii_whitespace().find_map(|token| { + let (candidate_label, value) = token.split_once('=')?; + candidate_label.eq_ignore_ascii_case(label).then_some(value) + }) +} + +fn optional_safe_field(message: &str, label: &str) -> Option { + safe_value(message_field(message, label)?) +} + +fn optional_safe_handle(message: &str, label: &str, prefix: &str) -> Option { + let value = safe_value(message_field(message, label)?)?; + let opaque = value.strip_prefix(prefix)?; + (!opaque.is_empty() + && opaque.len() <= 64 + && opaque + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))) + .then_some(value) +} + +fn subject_key(key: &SccmClientUpdateKey) -> UpdateSubjectKey { + ( + key.update_id.clone(), + key.ci_id.clone(), + key.content_id.clone(), + key.update_job_id.clone(), + key.client_handle.clone(), + key.site_code.clone(), + key.sup_host_handle.clone(), + ) +} + +fn subject_discriminator(key: &SccmClientUpdateKey) -> String { + let mut hasher = Sha256::new(); + for value in [ + Some(key.update_id.as_str()), + Some(key.ci_id.as_str()), + key.content_id.as_deref(), + key.update_job_id.as_deref(), + key.client_handle.as_deref(), + key.site_code.as_deref(), + key.sup_host_handle.as_deref(), + ] { + match value { + Some(value) => { + hasher.update([1]); + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value.as_bytes()); + } + None => hasher.update([0]), + } + } + hasher.finalize()[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn update_coverage_state(state: &SccmCoverageState) -> SccmClientUpdateCoverageState { + match state { + SccmCoverageState::Captured => SccmClientUpdateCoverageState::Captured, + SccmCoverageState::Absent => SccmClientUpdateCoverageState::Absent, + SccmCoverageState::Skipped => SccmClientUpdateCoverageState::Skipped, + SccmCoverageState::Unsupported => SccmClientUpdateCoverageState::Unsupported, + SccmCoverageState::ParseFailed => SccmClientUpdateCoverageState::ParseFailed, + SccmCoverageState::Capped => SccmClientUpdateCoverageState::Capped, + SccmCoverageState::AccessDenied => SccmClientUpdateCoverageState::AccessDenied, + } +} + +fn safe_value(value: &str) -> Option { + let value = value.trim_matches(|character| matches!(character, '{' | '}' | ',' | ';')); + (!value.is_empty() + && value.len() <= 160 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))) + .then(|| value.to_owned()) +} + +fn normalize_update_id(value: &str) -> Option { + let normalized = safe_value(value)?.to_ascii_lowercase(); + let bytes = normalized.as_bytes(); + if bytes.len() != 36 + || ![8, 13, 18, 23] + .into_iter() + .all(|index| bytes.get(index) == Some(&b'-')) + || bytes + .iter() + .enumerate() + .any(|(index, byte)| ![8, 13, 18, 23].contains(&index) && !byte.is_ascii_hexdigit()) + { + return None; + } + Some(normalized) +} + +fn phase_name(phase: SccmClientUpdatePhase) -> &'static str { + match phase { + SccmClientUpdatePhase::Scan => "scan", + SccmClientUpdatePhase::Evaluate => "evaluate", + SccmClientUpdatePhase::LocateSup => "locate-sup", + SccmClientUpdatePhase::Download => "download", + SccmClientUpdatePhase::MaintenanceWindow => "maintenance-window", + SccmClientUpdatePhase::Install => "install", + SccmClientUpdatePhase::Reboot => "reboot", + SccmClientUpdatePhase::Report => "report", + } +} + +fn request_for(phase: SccmClientUpdatePhase) -> SccmClientUpdateArtifactRequest { + let logical_artifact_id = match phase { + SccmClientUpdatePhase::Scan | SccmClientUpdatePhase::Evaluate => "client-updates", + SccmClientUpdatePhase::LocateSup => "client-location-services-shared", + SccmClientUpdatePhase::Download => "client-content", + SccmClientUpdatePhase::MaintenanceWindow => "client-maintenance-window", + SccmClientUpdatePhase::Install => "client-updates", + SccmClientUpdatePhase::Reboot => "client-reboot", + SccmClientUpdatePhase::Report => "client-policy-state", + }; + SccmClientUpdateArtifactRequest { + logical_artifact_id: logical_artifact_id.to_owned(), + reason: format!( + "Collect the smallest bounded {logical_artifact_id} continuation for this exact update subject." + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fact( + artifact_id: &str, + disposition: PhaseDisposition, + ordering_state: SccmTimeOrderingState, + utc_millis: Option, + ) -> UpdateFact { + UpdateFact { + key: SccmClientUpdateKey { + update_id: "32300000-0000-0000-0000-000000000003".to_owned(), + ci_id: "323003".to_owned(), + content_id: None, + update_job_id: None, + client_handle: None, + site_code: None, + sup_host_handle: None, + confidence: SccmKeyConfidence::Low, + extraction_profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + }, + phase: SccmClientUpdatePhase::Install, + disposition, + evidence: SccmEvidenceRef { + artifact_id: artifact_id.to_owned(), + entry_id: format!("entry:{artifact_id}"), + line_start: Some(1), + line_end: Some(1), + }, + timestamp: SccmTimestamp { + original_display: None, + offset_minutes: None, + utc_millis, + ordering_state, + }, + location_services_source: false, + } + } + + #[test] + fn opposing_comparable_and_noncomparable_facts_fail_closed_as_contradictory() { + let unordered = fact( + "fixture-update-a", + PhaseDisposition::Failed, + SccmTimeOrderingState::OffsetMissing, + None, + ); + let ordered = fact( + "fixture-update-b", + PhaseDisposition::Succeeded, + SccmTimeOrderingState::NormalizedUtc, + Some(1_000), + ); + + let effective = effective_phase_fact(&[&unordered, &ordered]); + assert_eq!(effective.disposition, PhaseDisposition::Contradictory); + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/correlation.rs b/crates/cmtraceopen-parser/src/sccm/correlation.rs new file mode 100644 index 000000000..aa1acb7a2 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/correlation.rs @@ -0,0 +1,995 @@ +//! Deterministic, conservative correlation of independently reduced SCCM evidence. +//! +//! This layer borrows only public analyzer output. Pair adapters immediately +//! project counterpart-ready facts into a bounded private representation; the +//! shared reducer never parses raw records and never mutates either source +//! analysis. + +use std::collections::BTreeSet; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use super::client::{ + SccmClientUpdateCoverageState, SccmClientUpdatesAnalysis, SccmDeploymentAnalysis, + SccmDeploymentProfileSelectionState, SccmPolicyAnalysis, SccmPolicyCondition, + SccmPolicyProfileSelectionState, SccmPolicyState, SCCM_DEPLOYMENT_PROFILE_ID, +}; +use super::server::windows::{ + SccmDistributionPointContentAnalysis, SccmDistributionPointContentState, + SccmManagementPointAnalysis, SccmManagementPointState, SccmSoftwareUpdatePointAnalysis, + SccmSoftwareUpdatePointDisposition, SccmSoftwareUpdatePointProfileSelection, + SccmSoftwareUpdatePointSourceLocalClassification, SccmSoftwareUpdatePointState, + SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_ID, SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_VERSION, + SCCM_MANAGEMENT_POINT_TEST_PROFILE_ID, SCCM_SOFTWARE_UPDATE_POINT_PROFILE_ID, +}; +use super::{ + SccmCorrelationKeyKind, SccmCoverageState, SccmKeyConfidence, SccmTimeOrderingState, + SCCM_EXPERIMENTAL_KEY_PROFILE_ID, SCCM_POLICY_KEY_PROFILE_ID, +}; + +pub const SCCM_CORRELATION_SCHEMA_VERSION: u32 = 1; +pub const SCCM_CORRELATION_IMPLEMENTATION_MODULE: &str = "sccm::correlation"; +const MAX_FACTS_PER_SIDE: usize = 128; +const MAX_RESULTS: usize = 256; + +const ALL_GUARDS: [SccmCorrelationGuard; 13] = [ + SccmCorrelationGuard::ConflictingExactKey, + SccmCorrelationGuard::IncompatibleTopology, + SccmCorrelationGuard::InvalidTimestampOffset, + SccmCorrelationGuard::MissingClientCounterpart, + SccmCorrelationGuard::MissingServerCounterpart, + SccmCorrelationGuard::PartialCapture, + SccmCorrelationGuard::RedactionBoundary, + SccmCorrelationGuard::ReorderedInput, + SccmCorrelationGuard::RotationSplit, + SccmCorrelationGuard::SameTimeNoKey, + SccmCorrelationGuard::UnknownExtractionProfile, + SccmCorrelationGuard::UnrelatedTerminalError, + SccmCorrelationGuard::VersionMismatch, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCorrelationPair { + ContentDistributionPoint, + PolicyManagementPoint, + UpdatesSoftwareUpdatePoint, +} + +impl SccmCorrelationPair { + fn stable_name(self) -> &'static str { + match self { + Self::ContentDistributionPoint => "content-distribution-point", + Self::PolicyManagementPoint => "policy-management-point", + Self::UpdatesSoftwareUpdatePoint => "updates-software-update-point", + } + } + + fn client_request(self) -> &'static str { + match self { + Self::ContentDistributionPoint => "client-content", + Self::PolicyManagementPoint => "client-policy-agent", + Self::UpdatesSoftwareUpdatePoint => "client-updates", + } + } + + fn server_request(self) -> &'static str { + match self { + Self::ContentDistributionPoint => "server-dp-distribution", + Self::PolicyManagementPoint => "server-mp-policy", + Self::UpdatesSoftwareUpdatePoint => "server-sup-sync", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCorrelationOutcome { + CausalFinding, + CandidateOnly, + CounterpartRequested, + CoverageGap, + Incompatible, + NotCausal, + ProfileGap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCorrelationLinkStrength { + ExactCorroborated, + ExactPartial, + Candidate, + Incompatible, + Unlinked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCorrelationConfidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SccmCorrelationGuard { + ConflictingExactKey, + IncompatibleTopology, + InvalidTimestampOffset, + MissingClientCounterpart, + MissingServerCounterpart, + PartialCapture, + RedactionBoundary, + ReorderedInput, + RotationSplit, + SameTimeNoKey, + UnknownExtractionProfile, + UnrelatedTerminalError, + VersionMismatch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCorrelationGuardState { + Passed, + Triggered, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmCorrelationGuardCheck { + pub guard_id: SccmCorrelationGuard, + pub state: SccmCorrelationGuardState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SccmCorrelationReason { + ClientCounterpartMissing, + ExactKeyConflict, + InputCapExceeded, + OrderingNotCausal, + OrderingUnavailable, + PartialSourceCoverage, + ProfileUnvalidated, + ProfileVersionMismatch, + RotationIncomplete, + SameTimeWithoutExactKey, + ServerCounterpartMissing, + TerminalRelationMissing, + TopologyMismatch, + UnrelatedServerTerminal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCorrelationSide { + Client, + Server, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmCorrelationArtifactRequest { + pub side: SccmCorrelationSide, + pub logical_artifact_id: String, + pub reason_code: SccmCorrelationReason, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmCorrelationResult { + pub result_id: String, + pub outcome: SccmCorrelationOutcome, + pub link_strength: SccmCorrelationLinkStrength, + pub confidence: SccmCorrelationConfidence, + pub guard_checks: Vec, + pub reason_codes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_fact_handle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_fact_handle: Option, + pub artifact_requests: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmCorrelationAnalysis { + pub schema_version: u32, + pub pair: SccmCorrelationPair, + pub source_findings_preserved: bool, + pub results: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProfileAuthority { + Validated, + Unknown, + VersionMismatch, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct CanonicalFact { + exact_key: String, + topology: String, + utc_millis: Option, + ordering_usable: bool, + terminal_failure: bool, + stable_source: String, +} + +impl CanonicalFact { + fn handle(&self, side: SccmCorrelationSide, pair: SccmCorrelationPair) -> String { + let preimage = format!( + "{}|{:?}|{}|{}|{:?}|{}|{}", + pair.stable_name(), + side, + self.exact_key, + self.topology, + self.utc_millis, + self.terminal_failure, + self.stable_source + ); + format!("corrfact:sha256:{}", sha256_hex(preimage.as_bytes())) + } +} + +#[derive(Debug, Clone)] +struct CanonicalInput { + pair: SccmCorrelationPair, + client_facts: Vec, + server_facts: Vec, + client_profile: ProfileAuthority, + server_profile: ProfileAuthority, + client_coverage_complete: bool, + server_coverage_complete: bool, + client_rotation_complete: bool, + server_rotation_complete: bool, + input_capped: bool, +} + +impl CanonicalInput { + fn normalize(mut self) -> Self { + self.client_facts.sort(); + self.client_facts.dedup(); + self.server_facts.sort(); + self.server_facts.dedup(); + self.input_capped |= self.client_facts.len() > MAX_FACTS_PER_SIDE + || self.server_facts.len() > MAX_FACTS_PER_SIDE; + self.client_facts.truncate(MAX_FACTS_PER_SIDE); + self.server_facts.truncate(MAX_FACTS_PER_SIDE); + self + } +} + +#[derive(Debug, Clone)] +pub struct SccmPolicyManagementPointInput { + canonical: CanonicalInput, +} + +impl SccmPolicyManagementPointInput { + pub fn from_analyses( + client: &SccmPolicyAnalysis, + server: &SccmManagementPointAnalysis, + ) -> Self { + let mut client_profile = match ( + client.extraction_profile.selection_state, + client.extraction_profile.profile_id.as_deref(), + ) { + (SccmPolicyProfileSelectionState::Selected, Some(SCCM_POLICY_KEY_PROFILE_ID)) => { + ProfileAuthority::Validated + } + (SccmPolicyProfileSelectionState::Selected, Some(_)) => { + ProfileAuthority::VersionMismatch + } + _ => ProfileAuthority::Unknown, + }; + if client_profile == ProfileAuthority::Validated + && client.transactions.iter().any(|transaction| { + transaction.key.extraction_profile_id != SCCM_POLICY_KEY_PROFILE_ID + }) + { + client_profile = ProfileAuthority::VersionMismatch; + } + let client_facts = client + .transactions + .iter() + .filter_map(|transaction| { + let request_id = transaction.key.request_id.as_ref()?; + let site_code = unique_exact_site_code(&transaction.correlation_keys)?; + let observation = transaction.observations.iter().max_by(|left, right| { + left.timestamp + .utc_millis + .cmp(&right.timestamp.utc_millis) + .then_with(|| left.observation_id.cmp(&right.observation_id)) + })?; + Some(CanonicalFact { + exact_key: format!("policy={}|request={request_id}", transaction.key.policy_id), + topology: format!("site={site_code}"), + utc_millis: observation.timestamp.utc_millis, + ordering_usable: observation.timestamp.ordering_state + == SccmTimeOrderingState::NormalizedUtc + && observation.timestamp.utc_millis.is_some(), + terminal_failure: transaction.state == SccmPolicyState::Failed + && transaction.observations.iter().any(|item| item.terminal), + stable_source: transaction.transaction_id.clone(), + }) + }) + .collect(); + let server_facts = server + .counterpart_ready_facts + .iter() + .filter_map(|fact| { + let policy_id = fact.key.policy_id.as_ref()?; + Some(CanonicalFact { + exact_key: format!("policy={policy_id}|request={}", fact.key.request_id), + topology: format!("site={}", fact.key.site_code), + utc_millis: fact.timestamp.utc_millis, + ordering_usable: fact.timestamp.ordering_state + == SccmTimeOrderingState::NormalizedUtc + && fact.timestamp.utc_millis.is_some(), + terminal_failure: fact.state == SccmManagementPointState::Failed + && fact.terminal_evidence.is_some(), + stable_source: fact.transaction_id.clone(), + }) + }) + .collect::>(); + let server_profile = profile_authority( + server + .counterpart_ready_facts + .iter() + .map(|fact| fact.key.extraction_profile_id.as_str()), + SCCM_MANAGEMENT_POINT_TEST_PROFILE_ID, + ); + Self { + canonical: CanonicalInput { + pair: SccmCorrelationPair::PolicyManagementPoint, + client_facts, + server_facts, + client_profile, + server_profile, + client_coverage_complete: !client.coverage.is_empty() + && client + .coverage + .iter() + .all(|coverage| coverage.state == SccmCoverageState::Captured) + && client.profile_gaps.is_empty(), + server_coverage_complete: server.coverage_gaps.is_empty(), + client_rotation_complete: !client + .source_local_observations + .iter() + .any(|item| item.condition == SccmPolicyCondition::RotationSplit), + server_rotation_complete: !server + .source_local_observations + .iter() + .any(|item| item.observation_id.contains("rotation")), + input_capped: false, + } + .normalize(), + } + } +} + +#[derive(Debug, Clone)] +pub struct SccmContentDistributionPointInput { + canonical: CanonicalInput, +} + +impl SccmContentDistributionPointInput { + pub fn from_analyses( + client: &SccmDeploymentAnalysis, + server: &SccmDistributionPointContentAnalysis, + ) -> Self { + let mut client_profile = match client.extraction_profile.selection_state { + SccmDeploymentProfileSelectionState::Selected + if client.extraction_profile.profile_id == SCCM_DEPLOYMENT_PROFILE_ID => + { + ProfileAuthority::Validated + } + SccmDeploymentProfileSelectionState::Selected => ProfileAuthority::VersionMismatch, + SccmDeploymentProfileSelectionState::Unselected => ProfileAuthority::Unknown, + }; + if client_profile == ProfileAuthority::Validated + && client + .transactions + .iter() + .filter_map(|transaction| transaction.counterpart_ready_fact.as_ref()) + .any(|fact| fact.extraction_profile_id != SCCM_DEPLOYMENT_PROFILE_ID) + { + client_profile = ProfileAuthority::VersionMismatch; + } + let client_facts = client + .transactions + .iter() + .filter_map(|transaction| transaction.counterpart_ready_fact.as_ref()) + .map(|fact| { + let utc_millis = parse_rfc3339_millis(&fact.timestamp_provenance.normalized_utc); + CanonicalFact { + exact_key: format!( + "package={}|content={}|version={}", + fact.package_id, fact.content_id, fact.content_version + ), + topology: format!("dp={}", fact.distribution_point_host_handle), + utc_millis, + ordering_usable: utc_millis.is_some(), + terminal_failure: false, + stable_source: fact.request_id.clone(), + } + }) + .collect(); + let server_facts = server + .transactions + .iter() + .filter_map(|transaction| { + let observation = terminal_or_latest_dp_observation(transaction)?; + Some(CanonicalFact { + exact_key: format!( + "package={}|content={}|version={}", + transaction.key.package_id, + transaction.key.content_id, + transaction.key.content_version + ), + topology: format!("dp={}", transaction.key.distribution_point_handle), + utc_millis: observation.timestamp.utc_millis, + ordering_usable: observation.timestamp.ordering_state + == SccmTimeOrderingState::NormalizedUtc + && observation.timestamp.utc_millis.is_some(), + terminal_failure: transaction.state + == SccmDistributionPointContentState::Failed + && observation.terminal + && !transaction.recovered, + stable_source: transaction.transaction_id.clone(), + }) + }) + .collect::>(); + let server_profile = if server.profile.id == SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_ID + && server.profile.version == SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_VERSION + && server.transactions.iter().all(|transaction| { + transaction.key.extraction_profile_id == SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_ID + && transaction.key.extraction_profile_version + == SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_VERSION + }) { + ProfileAuthority::Validated + } else if server.profile.id.is_empty() { + ProfileAuthority::Unknown + } else { + ProfileAuthority::VersionMismatch + }; + Self { + canonical: CanonicalInput { + pair: SccmCorrelationPair::ContentDistributionPoint, + client_facts, + server_facts, + client_profile, + server_profile, + client_coverage_complete: !client.coverage.is_empty() + && client.coverage.iter().all(|coverage| { + coverage.state == SccmCoverageState::Captured && coverage.capture_complete + }) + && client.coverage_gaps.is_empty(), + server_coverage_complete: server.coverage_gaps.is_empty() + && server + .transactions + .iter() + .all(|transaction| !transaction.content_version_mismatch), + client_rotation_complete: !client + .source_local_observations + .iter() + .any(|item| item.reason.to_ascii_lowercase().contains("rotation")), + server_rotation_complete: !server + .coverage_gaps + .iter() + .any(|gap| gap.reason.to_ascii_lowercase().contains("rotation")), + input_capped: false, + } + .normalize(), + } + } +} + +#[derive(Debug, Clone)] +pub struct SccmUpdatesSoftwareUpdatePointInput { + canonical: CanonicalInput, +} + +impl SccmUpdatesSoftwareUpdatePointInput { + pub fn from_analyses( + client: &SccmClientUpdatesAnalysis, + server: &SccmSoftwareUpdatePointAnalysis, + ) -> Self { + let mut client_profile = + if client.extraction_profile.profile_id == SCCM_EXPERIMENTAL_KEY_PROFILE_ID { + ProfileAuthority::Validated + } else if client.extraction_profile.profile_id.is_empty() { + ProfileAuthority::Unknown + } else { + ProfileAuthority::VersionMismatch + }; + if client_profile == ProfileAuthority::Validated + && client + .correlation_handoff + .counterpart_ready_facts + .iter() + .any(|fact| fact.extraction_profile_id != SCCM_EXPERIMENTAL_KEY_PROFILE_ID) + { + client_profile = ProfileAuthority::VersionMismatch; + } + let client_facts = client + .correlation_handoff + .counterpart_ready_facts + .iter() + .filter(|fact| fact.key_confidence == SccmKeyConfidence::Exact) + .map(|fact| CanonicalFact { + exact_key: format!("update={}", fact.update_id), + topology: format!("site={}|sup={}", fact.site_code, fact.sup_host_handle), + utc_millis: Some(fact.timestamp_provenance.utc_millis), + ordering_usable: fact.timestamp_provenance.ordering_state + == SccmTimeOrderingState::NormalizedUtc, + terminal_failure: false, + stable_source: format!( + "{}:{}:{}", + fact.evidence.artifact_id, fact.evidence.start_line, fact.evidence.end_line + ), + }) + .collect(); + let server_facts = server + .transactions + .iter() + .filter(|transaction| transaction.correlation_eligible) + .filter_map(|transaction| { + let update_id = transaction.key.update_id.as_ref()?; + let observation = terminal_or_latest_sup_observation(transaction)?; + let utc_millis = observation.timestamp.utc_millis; + Some(CanonicalFact { + exact_key: format!("update={update_id}"), + topology: format!( + "site={}|sup={}", + transaction.key.site_code, transaction.key.sup_handle + ), + utc_millis, + ordering_usable: observation.timestamp.ordering_state + == SccmTimeOrderingState::NormalizedUtc + && utc_millis.is_some(), + terminal_failure: transaction.state == SccmSoftwareUpdatePointState::Failed + && observation.terminal + && observation.disposition == SccmSoftwareUpdatePointDisposition::Failed, + stable_source: transaction.transaction_id.clone(), + }) + }) + .collect::>(); + let mut server_profile = match ( + server.extraction_profile.selection_state, + server.extraction_profile.profile_id.as_deref(), + ) { + ( + SccmSoftwareUpdatePointProfileSelection::SelectedSynthetic, + Some(SCCM_SOFTWARE_UPDATE_POINT_PROFILE_ID), + ) => ProfileAuthority::Validated, + (SccmSoftwareUpdatePointProfileSelection::SelectedSynthetic, Some(_)) => { + ProfileAuthority::VersionMismatch + } + _ => ProfileAuthority::Unknown, + }; + if server_profile == ProfileAuthority::Validated + && server.transactions.iter().any(|transaction| { + transaction.key.extraction_profile_id != SCCM_SOFTWARE_UPDATE_POINT_PROFILE_ID + }) + { + server_profile = ProfileAuthority::VersionMismatch; + } + Self { + canonical: CanonicalInput { + pair: SccmCorrelationPair::UpdatesSoftwareUpdatePoint, + client_facts, + server_facts, + client_profile, + server_profile, + client_coverage_complete: !client.coverage.is_empty() + && client + .coverage + .iter() + .all(|coverage| coverage.state == SccmClientUpdateCoverageState::Captured) + && client + .transactions + .iter() + .all(|transaction| transaction.coverage_gap_artifact_ids.is_empty()), + server_coverage_complete: !server.coverage.is_empty() + && server + .coverage + .iter() + .all(|coverage| coverage.state == SccmCoverageState::Captured) + && server + .transactions + .iter() + .all(|transaction| transaction.coverage_gap_artifact_ids.is_empty()), + client_rotation_complete: !client + .observations + .iter() + .any(|item| item.reason.to_ascii_lowercase().contains("rotation")), + server_rotation_complete: !server.source_local_observations.iter().any(|item| { + item.classification + == SccmSoftwareUpdatePointSourceLocalClassification::RotationSplit + }), + input_capped: false, + } + .normalize(), + } + } +} + +pub fn correlate_policy_management_point( + input: &SccmPolicyManagementPointInput, +) -> SccmCorrelationAnalysis { + correlate(&input.canonical) +} + +pub fn correlate_content_distribution_point( + input: &SccmContentDistributionPointInput, +) -> SccmCorrelationAnalysis { + correlate(&input.canonical) +} + +pub fn correlate_updates_software_update_point( + input: &SccmUpdatesSoftwareUpdatePointInput, +) -> SccmCorrelationAnalysis { + correlate(&input.canonical) +} + +fn correlate(input: &CanonicalInput) -> SccmCorrelationAnalysis { + let mut results = Vec::new(); + if input.client_facts.is_empty() || input.server_facts.is_empty() { + results.push(reduce_missing(input)); + } else { + for client in &input.client_facts { + if results.len() == MAX_RESULTS { + break; + } + results.push(reduce_client_fact(input, client)); + } + } + results.sort_by(|left, right| left.result_id.cmp(&right.result_id)); + results.dedup_by(|left, right| left.result_id == right.result_id); + SccmCorrelationAnalysis { + schema_version: SCCM_CORRELATION_SCHEMA_VERSION, + pair: input.pair, + source_findings_preserved: true, + results, + } +} + +fn reduce_missing(input: &CanonicalInput) -> SccmCorrelationResult { + let client_missing = input.client_facts.is_empty(); + let server_missing = input.server_facts.is_empty(); + let mut triggered = BTreeSet::new(); + let mut reasons = BTreeSet::new(); + let mut requests = Vec::new(); + if client_missing { + triggered.insert(SccmCorrelationGuard::MissingClientCounterpart); + reasons.insert(SccmCorrelationReason::ClientCounterpartMissing); + requests.push(SccmCorrelationArtifactRequest { + side: SccmCorrelationSide::Client, + logical_artifact_id: input.pair.client_request().to_owned(), + reason_code: SccmCorrelationReason::ClientCounterpartMissing, + }); + } + if server_missing { + triggered.insert(SccmCorrelationGuard::MissingServerCounterpart); + reasons.insert(SccmCorrelationReason::ServerCounterpartMissing); + requests.push(SccmCorrelationArtifactRequest { + side: SccmCorrelationSide::Server, + logical_artifact_id: input.pair.server_request().to_owned(), + reason_code: SccmCorrelationReason::ServerCounterpartMissing, + }); + } + apply_global_guards(input, &mut triggered, &mut reasons, &mut requests); + build_result( + input.pair, + SccmCorrelationOutcome::CounterpartRequested, + SccmCorrelationLinkStrength::Unlinked, + SccmCorrelationConfidence::Low, + triggered, + reasons, + None, + None, + requests, + ) +} + +fn reduce_client_fact(input: &CanonicalInput, client: &CanonicalFact) -> SccmCorrelationResult { + let identity_matches = input + .server_facts + .iter() + .filter(|server| server.exact_key == client.exact_key) + .collect::>(); + let topology_matches = identity_matches + .iter() + .copied() + .filter(|server| server.topology == client.topology) + .collect::>(); + let mut triggered = BTreeSet::new(); + let mut reasons = BTreeSet::new(); + let mut requests = Vec::new(); + apply_global_guards(input, &mut triggered, &mut reasons, &mut requests); + + let server = if identity_matches.is_empty() { + triggered.insert(SccmCorrelationGuard::ConflictingExactKey); + reasons.insert(SccmCorrelationReason::ExactKeyConflict); + if input + .server_facts + .iter() + .any(|item| item.utc_millis == client.utc_millis && item.utc_millis.is_some()) + { + triggered.insert(SccmCorrelationGuard::SameTimeNoKey); + reasons.insert(SccmCorrelationReason::SameTimeWithoutExactKey); + } + if input.server_facts.iter().any(|item| item.terminal_failure) { + triggered.insert(SccmCorrelationGuard::UnrelatedTerminalError); + reasons.insert(SccmCorrelationReason::UnrelatedServerTerminal); + } + None + } else if topology_matches.is_empty() { + triggered.insert(SccmCorrelationGuard::IncompatibleTopology); + reasons.insert(SccmCorrelationReason::TopologyMismatch); + None + } else if topology_matches.len() != 1 { + triggered.insert(SccmCorrelationGuard::ConflictingExactKey); + reasons.insert(SccmCorrelationReason::ExactKeyConflict); + None + } else { + topology_matches.first().copied() + }; + + if let Some(server) = server { + if !client.ordering_usable || !server.ordering_usable { + triggered.insert(SccmCorrelationGuard::InvalidTimestampOffset); + reasons.insert(SccmCorrelationReason::OrderingUnavailable); + } else if server.utc_millis < client.utc_millis { + reasons.insert(SccmCorrelationReason::OrderingNotCausal); + } + if !server.terminal_failure { + reasons.insert(SccmCorrelationReason::TerminalRelationMissing); + } + } + + let profile_gap = triggered.contains(&SccmCorrelationGuard::UnknownExtractionProfile); + let incompatible = triggered.contains(&SccmCorrelationGuard::VersionMismatch) + || triggered.contains(&SccmCorrelationGuard::ConflictingExactKey) + || triggered.contains(&SccmCorrelationGuard::IncompatibleTopology); + let coverage_gap = triggered.contains(&SccmCorrelationGuard::PartialCapture) + || triggered.contains(&SccmCorrelationGuard::RotationSplit); + let exact = server.is_some() && reasons.is_empty(); + let same_time = triggered.contains(&SccmCorrelationGuard::SameTimeNoKey); + let (outcome, strength, confidence) = if exact { + ( + SccmCorrelationOutcome::CausalFinding, + SccmCorrelationLinkStrength::ExactCorroborated, + SccmCorrelationConfidence::High, + ) + } else if profile_gap { + ( + SccmCorrelationOutcome::ProfileGap, + SccmCorrelationLinkStrength::Candidate, + SccmCorrelationConfidence::Low, + ) + } else if same_time { + ( + SccmCorrelationOutcome::CandidateOnly, + SccmCorrelationLinkStrength::Candidate, + SccmCorrelationConfidence::Low, + ) + } else if incompatible { + ( + SccmCorrelationOutcome::Incompatible, + SccmCorrelationLinkStrength::Incompatible, + SccmCorrelationConfidence::Low, + ) + } else if coverage_gap { + ( + SccmCorrelationOutcome::CoverageGap, + SccmCorrelationLinkStrength::ExactPartial, + SccmCorrelationConfidence::Low, + ) + } else { + ( + SccmCorrelationOutcome::NotCausal, + if server.is_some() { + SccmCorrelationLinkStrength::ExactPartial + } else { + SccmCorrelationLinkStrength::Unlinked + }, + SccmCorrelationConfidence::Medium, + ) + }; + build_result( + input.pair, + outcome, + strength, + confidence, + triggered, + reasons, + Some(client.handle(SccmCorrelationSide::Client, input.pair)), + server.map(|fact| fact.handle(SccmCorrelationSide::Server, input.pair)), + requests, + ) +} + +fn apply_global_guards( + input: &CanonicalInput, + triggered: &mut BTreeSet, + reasons: &mut BTreeSet, + requests: &mut Vec, +) { + for authority in [input.client_profile, input.server_profile] { + match authority { + ProfileAuthority::Validated => {} + ProfileAuthority::Unknown => { + triggered.insert(SccmCorrelationGuard::UnknownExtractionProfile); + reasons.insert(SccmCorrelationReason::ProfileUnvalidated); + } + ProfileAuthority::VersionMismatch => { + triggered.insert(SccmCorrelationGuard::VersionMismatch); + reasons.insert(SccmCorrelationReason::ProfileVersionMismatch); + } + } + } + if !input.client_coverage_complete || !input.server_coverage_complete || input.input_capped { + triggered.insert(SccmCorrelationGuard::PartialCapture); + reasons.insert(SccmCorrelationReason::PartialSourceCoverage); + if input.input_capped { + reasons.insert(SccmCorrelationReason::InputCapExceeded); + } + if !input.client_coverage_complete { + requests.push(SccmCorrelationArtifactRequest { + side: SccmCorrelationSide::Client, + logical_artifact_id: input.pair.client_request().to_owned(), + reason_code: SccmCorrelationReason::PartialSourceCoverage, + }); + } + if !input.server_coverage_complete { + requests.push(SccmCorrelationArtifactRequest { + side: SccmCorrelationSide::Server, + logical_artifact_id: input.pair.server_request().to_owned(), + reason_code: SccmCorrelationReason::PartialSourceCoverage, + }); + } + } + if !input.client_rotation_complete || !input.server_rotation_complete { + triggered.insert(SccmCorrelationGuard::RotationSplit); + reasons.insert(SccmCorrelationReason::RotationIncomplete); + if !input.client_rotation_complete { + requests.push(SccmCorrelationArtifactRequest { + side: SccmCorrelationSide::Client, + logical_artifact_id: input.pair.client_request().to_owned(), + reason_code: SccmCorrelationReason::RotationIncomplete, + }); + } + if !input.server_rotation_complete { + requests.push(SccmCorrelationArtifactRequest { + side: SccmCorrelationSide::Server, + logical_artifact_id: input.pair.server_request().to_owned(), + reason_code: SccmCorrelationReason::RotationIncomplete, + }); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn build_result( + pair: SccmCorrelationPair, + outcome: SccmCorrelationOutcome, + link_strength: SccmCorrelationLinkStrength, + confidence: SccmCorrelationConfidence, + triggered: BTreeSet, + reasons: BTreeSet, + client_fact_handle: Option, + server_fact_handle: Option, + mut artifact_requests: Vec, +) -> SccmCorrelationResult { + artifact_requests.sort(); + artifact_requests.dedup(); + let guard_checks = ALL_GUARDS + .into_iter() + .map(|guard_id| SccmCorrelationGuardCheck { + guard_id, + state: if triggered.contains(&guard_id) { + SccmCorrelationGuardState::Triggered + } else { + SccmCorrelationGuardState::Passed + }, + }) + .collect::>(); + let reason_codes = reasons.into_iter().collect::>(); + let result_preimage = format!( + "{}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", + pair.stable_name(), + outcome, + link_strength, + confidence, + reason_codes, + client_fact_handle, + server_fact_handle + ); + SccmCorrelationResult { + result_id: format!("corr:sha256:{}", sha256_hex(result_preimage.as_bytes())), + outcome, + link_strength, + confidence, + guard_checks, + reason_codes, + client_fact_handle, + server_fact_handle, + artifact_requests, + } +} + +fn unique_exact_site_code(keys: &[super::SccmCorrelationKey]) -> Option { + let values = keys + .iter() + .filter(|key| { + key.kind == SccmCorrelationKeyKind::SiteCode + && key.confidence == SccmKeyConfidence::Exact + }) + .map(|key| key.normalized.clone()) + .collect::>(); + (values.len() == 1) + .then(|| values.into_iter().next()) + .flatten() +} + +fn profile_authority<'a>( + profiles: impl Iterator, + expected: &str, +) -> ProfileAuthority { + let profiles = profiles.collect::>(); + if profiles.is_empty() { + ProfileAuthority::Unknown + } else if profiles.len() == 1 && profiles.contains(expected) { + ProfileAuthority::Validated + } else { + ProfileAuthority::VersionMismatch + } +} + +fn terminal_or_latest_dp_observation( + transaction: &super::server::windows::SccmDistributionPointContentTransaction, +) -> Option<&super::server::windows::SccmDistributionPointContentObservation> { + transaction + .observations + .iter() + .filter(|observation| observation.terminal) + .max_by_key(|observation| observation.timestamp.utc_millis) + .or_else(|| { + transaction + .observations + .iter() + .max_by_key(|observation| observation.timestamp.utc_millis) + }) +} + +fn terminal_or_latest_sup_observation( + transaction: &super::server::windows::SccmSoftwareUpdatePointTransaction, +) -> Option<&super::server::windows::SccmSoftwareUpdatePointObservation> { + transaction + .observations + .iter() + .rfind(|observation| observation.terminal) + .or_else(|| transaction.observations.last()) +} + +fn parse_rfc3339_millis(value: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|timestamp| timestamp.timestamp_millis()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +#[path = "correlation_tests.rs"] +mod tests; diff --git a/crates/cmtraceopen-parser/src/sccm/correlation_tests.rs b/crates/cmtraceopen-parser/src/sccm/correlation_tests.rs new file mode 100644 index 000000000..ac2e628a4 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/correlation_tests.rs @@ -0,0 +1,1000 @@ +use super::*; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OracleMatrix { + schema_version: String, + pair: String, + scenarios: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OracleScenario { + scenario_id: String, + mutation: OracleMutation, + expected_outcome: String, + expected_link_strength: String, + expected_confidence: String, + expected_reason_codes: Vec, + expected_triggered_guards: Vec, + expected_output_sha256: String, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum OracleMutation { + ConflictingExactKey, + Healthy, + IncompatibleTopology, + InvalidTimestampOffset, + MissingClientCounterpart, + MissingServerCounterpart, + PartialCapture, + RedactionBoundary, + ReorderedInputA, + ReorderedInputB, + RotationSplit, + SameTimeNoKey, + UnknownExtractionProfile, + UnrelatedTerminalError, + VersionMismatch, +} + +fn fact(key: &str, topology: &str, time: Option, terminal: bool) -> CanonicalFact { + CanonicalFact { + exact_key: key.to_owned(), + topology: topology.to_owned(), + utc_millis: time, + ordering_usable: time.is_some(), + terminal_failure: terminal, + stable_source: format!("source-{key}-{topology}-{time:?}-{terminal}"), + } +} + +fn evidence_ref(id: &str) -> crate::sccm::SccmEvidenceRef { + crate::sccm::SccmEvidenceRef { + artifact_id: format!("artifact-{id}"), + entry_id: format!("entry-{id}"), + line_start: Some(1), + line_end: Some(1), + } +} + +fn timestamp(utc_millis: i64) -> crate::sccm::SccmTimestamp { + crate::sccm::SccmTimestamp { + original_display: Some("01-01-1970 00:00:00.100+000".to_owned()), + offset_minutes: Some(0), + utc_millis: Some(utc_millis), + ordering_state: SccmTimeOrderingState::NormalizedUtc, + } +} + +fn healthy(pair: SccmCorrelationPair) -> CanonicalInput { + CanonicalInput { + pair, + client_facts: vec![fact("key=one", "topology=one", Some(100), false)], + server_facts: vec![fact("key=one", "topology=one", Some(200), true)], + client_profile: ProfileAuthority::Validated, + server_profile: ProfileAuthority::Validated, + client_coverage_complete: true, + server_coverage_complete: true, + client_rotation_complete: true, + server_rotation_complete: true, + input_capped: false, + } + .normalize() +} + +fn apply_mutation(mut input: CanonicalInput, mutation: OracleMutation) -> CanonicalInput { + match mutation { + OracleMutation::Healthy => {} + OracleMutation::ConflictingExactKey => { + input.server_facts[0].exact_key = "key=other".to_owned(); + input.server_facts[0].utc_millis = Some(300); + input.server_facts[0].terminal_failure = false; + } + OracleMutation::IncompatibleTopology => { + input.server_facts[0].topology = "topology=other".to_owned(); + } + OracleMutation::InvalidTimestampOffset => { + input.server_facts[0].ordering_usable = false; + input.server_facts[0].utc_millis = None; + } + OracleMutation::MissingClientCounterpart => input.client_facts.clear(), + OracleMutation::MissingServerCounterpart => input.server_facts.clear(), + OracleMutation::PartialCapture => input.server_coverage_complete = false, + OracleMutation::RedactionBoundary => { + input.client_facts[0].stable_source = + r"C:\Windows\CCM\Logs\PolicyAgent.log|LAB\SyntheticUser|Bearer secret-token" + .to_owned(); + input.server_facts[0].stable_source = "mp01.contoso.example".to_owned(); + } + OracleMutation::ReorderedInputA | OracleMutation::ReorderedInputB => { + input + .client_facts + .push(fact("key=two", "topology=two", Some(300), false)); + input + .server_facts + .push(fact("key=two", "topology=two", Some(400), true)); + if matches!(mutation, OracleMutation::ReorderedInputB) { + input.client_facts.reverse(); + input.server_facts.reverse(); + } + } + OracleMutation::RotationSplit => input.client_rotation_complete = false, + OracleMutation::SameTimeNoKey => { + input.server_facts[0].exact_key = "key=other".to_owned(); + input.server_facts[0].utc_millis = Some(100); + input.server_facts[0].terminal_failure = false; + } + OracleMutation::UnknownExtractionProfile => { + input.client_profile = ProfileAuthority::Unknown; + } + OracleMutation::UnrelatedTerminalError => { + input.server_facts[0].exact_key = "key=other".to_owned(); + input.server_facts[0].utc_millis = Some(300); + } + OracleMutation::VersionMismatch => { + input.server_profile = ProfileAuthority::VersionMismatch; + } + } + input.normalize() +} + +fn pair_from_fixture(value: &str) -> SccmCorrelationPair { + match value { + "contentDistributionPoint" => SccmCorrelationPair::ContentDistributionPoint, + "policyManagementPoint" => SccmCorrelationPair::PolicyManagementPoint, + "updatesSoftwareUpdatePoint" => SccmCorrelationPair::UpdatesSoftwareUpdatePoint, + other => panic!("unknown oracle pair {other}"), + } +} + +fn run_oracle_matrix(path: &str) { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/correlation"); + let bytes = std::fs::read(root.join(path)).expect("correlation oracle is readable"); + let matrix: OracleMatrix = serde_json::from_slice(&bytes).expect("typed oracle JSON"); + assert_eq!(matrix.schema_version, "1.0.0"); + let pair = pair_from_fixture(&matrix.pair); + assert_eq!(matrix.scenarios.len(), 15, "{path}"); + let mut scenario_ids = BTreeSet::new(); + for scenario in matrix.scenarios { + assert!( + scenario_ids.insert(scenario.scenario_id.clone()), + "{}: duplicate scenario {}", + path, + scenario.scenario_id + ); + let input = apply_mutation(healthy(pair), scenario.mutation); + let analysis = correlate(&input); + let output = serde_json::to_vec(&analysis).expect("analysis serializes"); + let output_hash = sha256_hex(&output); + assert_eq!( + output_hash, + scenario.expected_output_sha256, + "{}:{} exact output hash; actual JSON: {}", + path, + scenario.scenario_id, + String::from_utf8_lossy(&output) + ); + let value = serde_json::to_value(&analysis).expect("analysis is JSON"); + let first = &value["results"][0]; + assert_eq!( + first["outcome"], scenario.expected_outcome, + "{}", + scenario.scenario_id + ); + assert_eq!( + first["linkStrength"], scenario.expected_link_strength, + "{}", + scenario.scenario_id + ); + assert_eq!( + first["confidence"], scenario.expected_confidence, + "{}", + scenario.scenario_id + ); + let reasons = first["reasonCodes"] + .as_array() + .expect("reason codes") + .iter() + .map(|reason| reason.as_str().expect("reason string").to_owned()) + .collect::>(); + assert_eq!( + reasons, scenario.expected_reason_codes, + "{}", + scenario.scenario_id + ); + let triggered = first["guardChecks"] + .as_array() + .expect("guard checks") + .iter() + .filter(|check| check["state"] == "triggered") + .map(|check| check["guardId"].as_str().expect("guard ID").to_owned()) + .collect::>(); + assert_eq!( + triggered, scenario.expected_triggered_guards, + "{}", + scenario.scenario_id + ); + let serialized = String::from_utf8(output).expect("JSON is UTF-8"); + for marker in [ + r"C:\Windows\CCM\Logs", + "mp01.contoso.example", + r"LAB\SyntheticUser", + "secret-token", + ] { + assert!( + !serialized.contains(marker), + "{} leaked {marker}", + scenario.scenario_id + ); + } + } +} + +#[test] +fn healthy_exact_link_requires_every_gate() { + for pair in [ + SccmCorrelationPair::ContentDistributionPoint, + SccmCorrelationPair::PolicyManagementPoint, + SccmCorrelationPair::UpdatesSoftwareUpdatePoint, + ] { + let analysis = correlate(&healthy(pair)); + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.outcome, SccmCorrelationOutcome::CausalFinding); + assert_eq!( + result.link_strength, + SccmCorrelationLinkStrength::ExactCorroborated + ); + assert_eq!(result.confidence, SccmCorrelationConfidence::High); + assert!(result.reason_codes.is_empty()); + assert!(result + .guard_checks + .iter() + .all(|check| check.state == SccmCorrelationGuardState::Passed)); + } +} + +#[test] +fn all_three_public_adapters_can_emit_an_exact_corroborated_link() { + let policy_ref = evidence_ref("policy"); + let policy = crate::sccm::SccmPolicyAnalysis { + workflow: "policy".to_owned(), + state_chain: Vec::new(), + extraction_profile: crate::sccm::SccmPolicyExtractionProfile { + selection_state: SccmPolicyProfileSelectionState::Selected, + profile_id: Some(SCCM_POLICY_KEY_PROFILE_ID.to_owned()), + synthetic_fixture_only: true, + }, + coverage: vec![crate::sccm::SccmPolicyCoverage { + logical_artifact_id: "client-policy-agent".to_owned(), + state: SccmCoverageState::Captured, + artifact_ids: vec!["artifact-policy".to_owned()], + }], + profile_gaps: Vec::new(), + transactions: vec![crate::sccm::SccmPolicyTransaction { + transaction_id: "policy-transaction".to_owned(), + key: crate::sccm::SccmPolicyTransactionKey { + assignment_id: "assignment-safe".to_owned(), + policy_id: "policy-safe".to_owned(), + request_id: Some("request-safe".to_owned()), + extraction_profile_id: SCCM_POLICY_KEY_PROFILE_ID.to_owned(), + }, + phase: crate::sccm::SccmPolicyPhase::Request, + state: SccmPolicyState::Failed, + classification: crate::sccm::SccmPolicyClassification::ConfirmedFailure, + condition: Some(crate::sccm::SccmPolicyCondition::ProcessingFailure), + last_confirmed_phase: None, + confidence: crate::sccm::SccmConfidence::High, + correlation_keys: vec![crate::sccm::SccmCorrelationKey { + kind: SccmCorrelationKeyKind::SiteCode, + raw: "LAB".to_owned(), + normalized: "LAB".to_owned(), + confidence: SccmKeyConfidence::Exact, + extraction_profile_id: Some(SCCM_POLICY_KEY_PROFILE_ID.to_owned()), + evidence: Some(policy_ref.clone()), + start: Some(0), + end: Some(3), + }], + observations: vec![crate::sccm::SccmPolicyObservation { + observation_id: "policy-observation".to_owned(), + phase: crate::sccm::SccmPolicyPhase::Request, + outcome: crate::sccm::SccmPolicyObservationOutcome::Failed, + terminal: true, + timestamp: timestamp(100), + evidence: policy_ref.clone(), + }], + evidence: vec![policy_ref.clone()], + coverage_gaps: Vec::new(), + next_artifacts: Vec::new(), + }], + source_local_observations: Vec::new(), + findings: Vec::new(), + artifact_requests: Vec::new(), + cross_source_correlation_performed: false, + time_only_causality_allowed: false, + }; + let management_point = crate::sccm::server::windows::SccmManagementPointAnalysis { + schema_version: 1, + workflow: crate::sccm::server::windows::SccmServerWorkflow::ManagementPoint, + state_chain: Vec::new(), + transactions: Vec::new(), + source_local_observations: Vec::new(), + findings: Vec::new(), + coverage_gaps: Vec::new(), + artifact_requests: Vec::new(), + counterpart_ready_facts: vec![ + crate::sccm::server::windows::SccmManagementPointCounterpartReadyFact { + transaction_id: "mp-transaction".to_owned(), + key: crate::sccm::server::windows::SccmManagementPointKey { + request_id: "request-safe".to_owned(), + policy_id: Some("policy-safe".to_owned()), + client_handle: "client-safe".to_owned(), + site_code: "LAB".to_owned(), + management_point_host_handle: "mp-safe".to_owned(), + confidence: SccmKeyConfidence::Exact, + extraction_profile_id: SCCM_MANAGEMENT_POINT_TEST_PROFILE_ID.to_owned(), + }, + phase: crate::sccm::server::windows::SccmManagementPointPhase::Respond, + state: SccmManagementPointState::Failed, + classification: + crate::sccm::server::windows::SccmManagementPointClassification::ConfirmedFailure, + confidence: + crate::sccm::server::windows::SccmManagementPointConfidence::High, + timestamp: timestamp(200), + evidence: evidence_ref("mp"), + terminal_evidence: Some(evidence_ref("mp-terminal")), + }, + ], + cross_side_correlation_performed: false, + }; + + let content_ref = evidence_ref("content"); + let deployment = crate::sccm::SccmDeploymentAnalysis { + schema_version: 1, + workflow: crate::sccm::SccmDeploymentWorkflow::Deployment, + extraction_profile: crate::sccm::SccmDeploymentExtractionProfile { + selection_state: SccmDeploymentProfileSelectionState::Selected, + profile_id: SCCM_DEPLOYMENT_PROFILE_ID.to_owned(), + source_version_prefix: "5.00".to_owned(), + content_version_required: true, + key_kinds: Vec::new(), + validated_artifact_families: Vec::new(), + }, + coverage: vec![crate::sccm::SccmDeploymentCoverage { + logical_artifact_id: "client-content".to_owned(), + state: SccmCoverageState::Captured, + capture_complete: true, + artifact_ids: vec!["artifact-content".to_owned()], + }], + transactions: vec![crate::sccm::SccmDeploymentTransaction { + transaction_id: "deployment-transaction".to_owned(), + key: crate::sccm::SccmDeploymentKey { + key_profile_kind: + crate::sccm::SccmDeploymentKeyProfileKind::AssignmentCiContentTopology, + assignment_id: "assignment-safe".to_owned(), + ci_id: "1001".to_owned(), + package_id: Some("LAB00001".to_owned()), + content_id: Some("content-safe".to_owned()), + content_version: Some(1), + distribution_point_host_handle: Some("dp-safe".to_owned()), + request_id: Some("content-request".to_owned()), + bits_job_id: None, + product_code: None, + exit_code: None, + confidence: crate::sccm::SccmDeploymentKeyConfidence::Exact, + extraction_profile_id: SCCM_DEPLOYMENT_PROFILE_ID.to_owned(), + }, + counterpart_ready_fact: Some(crate::sccm::SccmDeploymentCounterpartFact { + fact_kind: crate::sccm::SccmDeploymentCounterpartFactKind::ClientContentRequest, + phase: crate::sccm::SccmDeploymentPhase::LocateContent, + extraction_profile_id: SCCM_DEPLOYMENT_PROFILE_ID.to_owned(), + package_id: "LAB00001".to_owned(), + content_id: "content-safe".to_owned(), + content_version: 1, + distribution_point_host_handle: "dp-safe".to_owned(), + request_id: "content-request".to_owned(), + timestamp_provenance: crate::sccm::SccmDeploymentTimestampProvenance { + kind: crate::sccm::SccmDeploymentTimestampProvenanceKind::ExplicitOffset, + offset_minutes: 0, + normalized_utc: "1970-01-01T00:00:00.100Z".to_owned(), + }, + evidence: content_ref.clone(), + }), + phase: crate::sccm::SccmDeploymentPhase::LocateContent, + state: crate::sccm::SccmDeploymentState::Failed, + last_successful_phase: None, + classification: crate::sccm::SccmDeploymentClassification::ConfirmedFailure, + confidence: crate::sccm::SccmDeploymentConfidence::High, + confidence_ceiling: crate::sccm::SccmDeploymentConfidence::High, + coverage_gap_artifact_ids: Vec::new(), + next_artifact: None, + evidence: vec![content_ref.clone()], + }], + source_local_observations: Vec::new(), + findings: Vec::new(), + coverage_gaps: Vec::new(), + artifact_requests: Vec::new(), + correlation_handoff: crate::sccm::SccmDeploymentCorrelationHandoff { + issue: "#333".to_owned(), + performed: false, + time_only_eligible: false, + topology_compatibility_evaluated: false, + server_cause_claimed: false, + counterpart_ready_key_kinds: Vec::new(), + emitted_counterpart_ready_fact: true, + }, + }; + let dp_observation = crate::sccm::server::windows::SccmDistributionPointContentObservation { + phase: crate::sccm::server::windows::SccmDistributionPointContentPhase::Transfer, + disposition: crate::sccm::server::windows::SccmDistributionPointContentDisposition::Failed, + terminal: true, + source_id: "server-dp-distribution".to_owned(), + timestamp: timestamp(200), + evidence: evidence_ref("dp"), + }; + let distribution_point = + crate::sccm::server::windows::SccmDistributionPointContentAnalysis { + schema_version: 1, + workflow: + crate::sccm::server::windows::SccmDistributionPointWorkflow::DistributionPointContent, + profile: crate::sccm::server::windows::SccmDistributionPointProfile { + id: SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_ID.to_owned(), + version: SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_VERSION, + stability: "synthetic".to_owned(), + }, + transactions: vec![ + crate::sccm::server::windows::SccmDistributionPointContentTransaction { + transaction_id: "dp-transaction".to_owned(), + key: crate::sccm::server::windows::SccmDistributionPointContentKey { + package_id: "LAB00001".to_owned(), + content_id: "content-safe".to_owned(), + content_version: 1, + topology_site_handle: "site-safe".to_owned(), + site_code: "LAB".to_owned(), + distribution_point_handle: "dp-safe".to_owned(), + extraction_profile_id: + SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_ID.to_owned(), + extraction_profile_version: + SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_VERSION, + }, + state: SccmDistributionPointContentState::Failed, + classification: crate::sccm::server::windows::SccmDistributionPointContentClassification::ConfirmedFailure, + confidence: crate::sccm::server::windows::SccmDistributionPointContentConfidence::High, + severity: crate::models::log_entry::Severity::Error, + scope: crate::sccm::server::windows::SccmDistributionPointContentScope::DistributionPointContent, + last_proven_phase: None, + stop_phase: Some(crate::sccm::server::windows::SccmDistributionPointContentPhase::Transfer), + recovered: false, + content_version_mismatch: false, + evidence: vec![dp_observation.evidence.clone()], + terminal_evidence: vec![dp_observation.evidence.clone()], + next_artifact: None, + observations: vec![dp_observation], + }, + ], + coverage_gaps: Vec::new(), + artifact_requests: Vec::new(), + cross_side_correlation_performed: false, + }; + + let updates = crate::sccm::SccmClientUpdatesAnalysis { + schema_version: 1, + transactions: Vec::new(), + observations: Vec::new(), + findings: Vec::new(), + coverage: vec![crate::sccm::SccmClientUpdateCoverage { + logical_artifact_id: "client-updates".to_owned(), + state: crate::sccm::SccmClientUpdateCoverageState::Captured, + }], + extraction_profile: crate::sccm::SccmClientUpdateExtractionProfile { + selection_state: "selected".to_owned(), + profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + key_confidence_ceiling: SccmKeyConfidence::Exact, + validated_artifact_families: Vec::new(), + }, + correlation_handoff: crate::sccm::SccmClientUpdateCorrelationHandoff { + issue: "#333".to_owned(), + server_prerequisite_issue: "#330".to_owned(), + performed: false, + time_only_eligible: false, + topology_compatibility_evaluated: false, + server_cause_claimed: false, + native_acceptance_claimed: false, + bundle_capture_host_used_as_sup_evidence: false, + counterpart_ready_key_kinds: Vec::new(), + emitted_counterpart_ready_fact: true, + counterpart_ready_facts: vec![crate::sccm::SccmClientUpdateCounterpartReadyFact { + update_id: "update-safe".to_owned(), + ci_id: "1001".to_owned(), + content_id: "content-safe".to_owned(), + update_job_id: "job-safe".to_owned(), + client_handle: "client-safe".to_owned(), + site_code: "LAB".to_owned(), + sup_host_handle: "sup-safe".to_owned(), + key_confidence: SccmKeyConfidence::Exact, + correlation_eligible: false, + time_only_eligible: false, + phase: crate::sccm::SccmClientUpdatePhase::LocateSup, + extraction_profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + timestamp_provenance: crate::sccm::SccmClientUpdateTimestampProvenance { + normalized_utc: "1970-01-01T00:00:00.100Z".to_owned(), + utc_millis: 100, + offset_minutes: 0, + ordering_state: SccmTimeOrderingState::NormalizedUtc, + }, + evidence: crate::sccm::SccmClientUpdateCounterpartEvidence { + artifact_id: "artifact-update".to_owned(), + start_line: 1, + end_line: 1, + }, + }], + }, + prohibited_claims: Vec::new(), + }; + let software_update_point = crate::sccm::server::windows::SccmSoftwareUpdatePointAnalysis { + workflow: crate::sccm::server::windows::SccmSoftwareUpdatePointWorkflow::SoftwareUpdatePoint, + state_chain: Vec::new(), + analysis_contract: crate::sccm::server::windows::SccmSoftwareUpdatePointAnalysisContract { + independent_reducer: true, + consumes_client_output: false, + cross_side_correlation_performed: false, + }, + extraction_profile: crate::sccm::server::windows::SccmSoftwareUpdatePointExtractionProfile { + selection_state: SccmSoftwareUpdatePointProfileSelection::SelectedSynthetic, + profile_id: Some(SCCM_SOFTWARE_UPDATE_POINT_PROFILE_ID.to_owned()), + validated_role: Some(crate::sccm::SccmRole::SoftwareUpdatePoint), + }, + role_assessment: crate::sccm::server::windows::SccmSoftwareUpdatePointRoleAssessment { + software_update_point_observed: true, + role_absent_inferred: false, + missing_default_path_interpretation: crate::sccm::server::windows::SccmSoftwareUpdatePointMissingPathInterpretation::SourceCoverageOnly, + }, + coverage: vec![crate::sccm::server::windows::SccmSoftwareUpdatePointCoverage { + artifact_id: "artifact-sup".to_owned(), + state: SccmCoverageState::Captured, + }], + transactions: vec![crate::sccm::server::windows::SccmSoftwareUpdatePointTransaction { + transaction_id: "sup-transaction".to_owned(), + key: crate::sccm::server::windows::SccmSoftwareUpdatePointKey { + sync_run_id: "sync-safe".to_owned(), + site_code: "LAB".to_owned(), + sup_handle: "sup-safe".to_owned(), + update_id: Some("update-safe".to_owned()), + kb_id: None, + confidence: crate::sccm::server::windows::SccmSoftwareUpdatePointKeyConfidence::Exact, + extraction_profile_id: SCCM_SOFTWARE_UPDATE_POINT_PROFILE_ID.to_owned(), + }, + topology_compatibility: crate::sccm::server::windows::SccmSoftwareUpdatePointTopologyCompatibility::Exact, + correlation_eligible: true, + state: SccmSoftwareUpdatePointState::Failed, + classification: crate::sccm::server::windows::SccmSoftwareUpdatePointClassification::ConfirmedFailure, + confidence: crate::sccm::server::windows::SccmSoftwareUpdatePointConfidence::High, + confidence_ceiling: crate::sccm::server::windows::SccmSoftwareUpdatePointConfidence::High, + last_successful_phase: None, + next_source_id: None, + coverage_gap_artifact_ids: Vec::new(), + observations: vec![crate::sccm::server::windows::SccmSoftwareUpdatePointObservation { + observation_id: "sup-terminal".to_owned(), + phase: crate::sccm::server::windows::SccmSoftwareUpdatePointPhase::HealthyOrTerminal, + disposition: SccmSoftwareUpdatePointDisposition::Failed, + terminal: true, + timestamp: timestamp(200), + evidence: vec![crate::sccm::server::windows::SccmSoftwareUpdatePointEvidence { + artifact_id: "artifact-sup".to_owned(), + start_line: 1, + end_line: 1, + }], + }], + }], + source_local_observations: Vec::new(), + artifact_requests: Vec::new(), + client_causal_claims: Vec::new(), + correlation_handoff: crate::sccm::server::windows::SccmSoftwareUpdatePointCorrelationHandoff { + issue: "#333".to_owned(), + performed: false, + time_only_eligible: false, + }, + }; + + let outputs = [ + correlate_policy_management_point(&SccmPolicyManagementPointInput::from_analyses( + &policy, + &management_point, + )), + correlate_content_distribution_point(&SccmContentDistributionPointInput::from_analyses( + &deployment, + &distribution_point, + )), + correlate_updates_software_update_point( + &SccmUpdatesSoftwareUpdatePointInput::from_analyses(&updates, &software_update_point), + ), + ]; + for output in outputs { + assert_eq!(output.results.len(), 1, "{:?}", output.pair); + assert_eq!( + output.results[0].link_strength, + SccmCorrelationLinkStrength::ExactCorroborated, + "{:?}", + output.pair + ); + assert_eq!( + output.results[0].confidence, + SccmCorrelationConfidence::High, + "{:?}", + output.pair + ); + } + + let assert_guard = |output: SccmCorrelationAnalysis, + guard: SccmCorrelationGuard, + reason: SccmCorrelationReason| { + let result = &output.results[0]; + assert!(result.guard_checks.iter().any(|check| { + check.guard_id == guard && check.state == SccmCorrelationGuardState::Triggered + })); + assert!(result.reason_codes.contains(&reason)); + assert_ne!( + result.link_strength, + SccmCorrelationLinkStrength::ExactCorroborated + ); + }; + + let mut policy_bad_time = policy.clone(); + policy_bad_time.transactions[0].observations[0] + .timestamp + .ordering_state = SccmTimeOrderingState::OffsetInvalid; + policy_bad_time.transactions[0].observations[0] + .timestamp + .utc_millis = None; + assert_guard( + correlate_policy_management_point(&SccmPolicyManagementPointInput::from_analyses( + &policy_bad_time, + &management_point, + )), + SccmCorrelationGuard::InvalidTimestampOffset, + SccmCorrelationReason::OrderingUnavailable, + ); + + let mut deployment_bad_time = deployment.clone(); + deployment_bad_time.transactions[0] + .counterpart_ready_fact + .as_mut() + .unwrap() + .timestamp_provenance + .normalized_utc = "not-rfc3339".to_owned(); + assert_guard( + correlate_content_distribution_point(&SccmContentDistributionPointInput::from_analyses( + &deployment_bad_time, + &distribution_point, + )), + SccmCorrelationGuard::InvalidTimestampOffset, + SccmCorrelationReason::OrderingUnavailable, + ); + + let mut policy_bad_profile = policy.clone(); + policy_bad_profile.transactions[0].key.extraction_profile_id = "wrong-profile".to_owned(); + let mut deployment_bad_profile = deployment.clone(); + deployment_bad_profile.transactions[0] + .counterpart_ready_fact + .as_mut() + .unwrap() + .extraction_profile_id = "wrong-profile".to_owned(); + let mut dp_bad_profile = distribution_point.clone(); + dp_bad_profile.transactions[0] + .key + .extraction_profile_version += 1; + let mut updates_bad_profile = updates.clone(); + updates_bad_profile + .correlation_handoff + .counterpart_ready_facts[0] + .extraction_profile_id = "wrong-profile".to_owned(); + let mut sup_bad_profile = software_update_point.clone(); + sup_bad_profile.transactions[0].key.extraction_profile_id = "wrong-profile".to_owned(); + for output in [ + correlate_policy_management_point(&SccmPolicyManagementPointInput::from_analyses( + &policy_bad_profile, + &management_point, + )), + correlate_content_distribution_point(&SccmContentDistributionPointInput::from_analyses( + &deployment_bad_profile, + &distribution_point, + )), + correlate_content_distribution_point(&SccmContentDistributionPointInput::from_analyses( + &deployment, + &dp_bad_profile, + )), + correlate_updates_software_update_point( + &SccmUpdatesSoftwareUpdatePointInput::from_analyses( + &updates_bad_profile, + &software_update_point, + ), + ), + correlate_updates_software_update_point( + &SccmUpdatesSoftwareUpdatePointInput::from_analyses(&updates, &sup_bad_profile), + ), + ] { + assert_guard( + output, + SccmCorrelationGuard::VersionMismatch, + SccmCorrelationReason::ProfileVersionMismatch, + ); + } +} + +#[test] +fn input_order_does_not_change_bytes() { + let mut first = healthy(SccmCorrelationPair::PolicyManagementPoint); + first + .client_facts + .push(fact("key=two", "topology=two", Some(300), false)); + first + .server_facts + .push(fact("key=two", "topology=two", Some(400), true)); + let first = first.normalize(); + let mut second = first.clone(); + second.client_facts.reverse(); + second.server_facts.reverse(); + let second = second.normalize(); + assert_eq!( + serde_json::to_vec(&correlate(&first)).unwrap(), + serde_json::to_vec(&correlate(&second)).unwrap() + ); +} + +#[test] +fn duplicate_exact_identity_and_recovered_terminal_fail_closed() { + let mut collision = healthy(SccmCorrelationPair::ContentDistributionPoint); + collision + .server_facts + .push(fact("key=one", "topology=one", Some(300), true)); + let collision = correlate(&collision.normalize()); + assert_eq!( + collision.results[0].link_strength, + SccmCorrelationLinkStrength::Incompatible + ); + assert_eq!( + collision.results[0].confidence, + SccmCorrelationConfidence::Low + ); + assert!(collision.results[0] + .reason_codes + .contains(&SccmCorrelationReason::ExactKeyConflict)); + + let mut recovered = healthy(SccmCorrelationPair::ContentDistributionPoint); + recovered.server_facts[0].terminal_failure = false; + let recovered = correlate(&recovered.normalize()); + assert_eq!( + recovered.results[0].outcome, + SccmCorrelationOutcome::NotCausal + ); + assert_eq!( + recovered.results[0].link_strength, + SccmCorrelationLinkStrength::ExactPartial + ); + assert_eq!( + recovered.results[0].confidence, + SccmCorrelationConfidence::Medium + ); + assert!(recovered.results[0] + .reason_codes + .contains(&SccmCorrelationReason::TerminalRelationMissing)); +} + +#[test] +fn every_guard_is_checked_for_every_pair() { + for pair in [ + SccmCorrelationPair::ContentDistributionPoint, + SccmCorrelationPair::PolicyManagementPoint, + SccmCorrelationPair::UpdatesSoftwareUpdatePoint, + ] { + let result = &correlate(&healthy(pair)).results[0]; + assert_eq!( + result + .guard_checks + .iter() + .map(|check| check.guard_id) + .collect::>(), + ALL_GUARDS + ); + } +} + +#[test] +fn raw_source_markers_never_enter_public_output() { + let markers = [ + r"C:\Windows\CCM\Logs\PolicyAgent.log", + "mp01.contoso.example", + r"LAB\SyntheticUser", + "Bearer secret-token", + ]; + for marker in markers { + let mut input = healthy(SccmCorrelationPair::PolicyManagementPoint); + input.client_facts[0].stable_source = marker.to_owned(); + let json = serde_json::to_string(&correlate(&input)).unwrap(); + assert!(!json.contains(marker), "leaked {marker}"); + } +} + +#[test] +fn typed_adapters_leave_all_source_analyses_byte_identical() { + let policy = crate::sccm::SccmPolicyAnalysis { + workflow: "policy".to_owned(), + state_chain: Vec::new(), + extraction_profile: crate::sccm::SccmPolicyExtractionProfile { + selection_state: crate::sccm::SccmPolicyProfileSelectionState::Unavailable, + profile_id: None, + synthetic_fixture_only: false, + }, + coverage: Vec::new(), + profile_gaps: Vec::new(), + transactions: Vec::new(), + source_local_observations: Vec::new(), + findings: Vec::new(), + artifact_requests: Vec::new(), + cross_source_correlation_performed: false, + time_only_causality_allowed: false, + }; + let management_point = crate::sccm::server::windows::SccmManagementPointAnalysis { + schema_version: 1, + workflow: crate::sccm::server::windows::SccmServerWorkflow::ManagementPoint, + state_chain: Vec::new(), + transactions: Vec::new(), + source_local_observations: Vec::new(), + findings: Vec::new(), + coverage_gaps: Vec::new(), + artifact_requests: Vec::new(), + counterpart_ready_facts: Vec::new(), + cross_side_correlation_performed: false, + }; + let deployment = crate::sccm::SccmDeploymentAnalysis { + schema_version: 1, + workflow: crate::sccm::SccmDeploymentWorkflow::Deployment, + extraction_profile: crate::sccm::SccmDeploymentExtractionProfile { + selection_state: crate::sccm::SccmDeploymentProfileSelectionState::Unselected, + profile_id: SCCM_DEPLOYMENT_PROFILE_ID.to_owned(), + source_version_prefix: String::new(), + content_version_required: true, + key_kinds: Vec::new(), + validated_artifact_families: Vec::new(), + }, + coverage: Vec::new(), + transactions: Vec::new(), + source_local_observations: Vec::new(), + findings: Vec::new(), + coverage_gaps: Vec::new(), + artifact_requests: Vec::new(), + correlation_handoff: crate::sccm::SccmDeploymentCorrelationHandoff { + issue: "#333".to_owned(), + performed: false, + time_only_eligible: false, + topology_compatibility_evaluated: false, + server_cause_claimed: false, + counterpart_ready_key_kinds: Vec::new(), + emitted_counterpart_ready_fact: false, + }, + }; + let distribution_point = crate::sccm::server::windows::SccmDistributionPointContentAnalysis { + schema_version: 1, + workflow: + crate::sccm::server::windows::SccmDistributionPointWorkflow::DistributionPointContent, + profile: crate::sccm::server::windows::SccmDistributionPointProfile { + id: SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_ID.to_owned(), + version: SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_VERSION, + stability: "synthetic".to_owned(), + }, + transactions: Vec::new(), + coverage_gaps: Vec::new(), + artifact_requests: Vec::new(), + cross_side_correlation_performed: false, + }; + let updates = crate::sccm::SccmClientUpdatesAnalysis { + schema_version: 1, + transactions: Vec::new(), + observations: Vec::new(), + findings: Vec::new(), + coverage: Vec::new(), + extraction_profile: crate::sccm::SccmClientUpdateExtractionProfile { + selection_state: "unavailable".to_owned(), + profile_id: String::new(), + key_confidence_ceiling: SccmKeyConfidence::Low, + validated_artifact_families: Vec::new(), + }, + correlation_handoff: crate::sccm::SccmClientUpdateCorrelationHandoff { + issue: "#333".to_owned(), + server_prerequisite_issue: "#330".to_owned(), + performed: false, + time_only_eligible: false, + topology_compatibility_evaluated: false, + server_cause_claimed: false, + native_acceptance_claimed: false, + bundle_capture_host_used_as_sup_evidence: false, + counterpart_ready_key_kinds: Vec::new(), + emitted_counterpart_ready_fact: false, + counterpart_ready_facts: Vec::new(), + }, + prohibited_claims: Vec::new(), + }; + let software_update_point = + crate::sccm::server::windows::SccmSoftwareUpdatePointAnalysis { + workflow: crate::sccm::server::windows::SccmSoftwareUpdatePointWorkflow::SoftwareUpdatePoint, + state_chain: Vec::new(), + analysis_contract: crate::sccm::server::windows::SccmSoftwareUpdatePointAnalysisContract { + independent_reducer: true, + consumes_client_output: false, + cross_side_correlation_performed: false, + }, + extraction_profile: crate::sccm::server::windows::SccmSoftwareUpdatePointExtractionProfile { + selection_state: + crate::sccm::server::windows::SccmSoftwareUpdatePointProfileSelection::Unavailable, + profile_id: None, + validated_role: None, + }, + role_assessment: crate::sccm::server::windows::SccmSoftwareUpdatePointRoleAssessment { + software_update_point_observed: false, + role_absent_inferred: false, + missing_default_path_interpretation: + crate::sccm::server::windows::SccmSoftwareUpdatePointMissingPathInterpretation::SourceCoverageOnly, + }, + coverage: Vec::new(), + transactions: Vec::new(), + source_local_observations: Vec::new(), + artifact_requests: Vec::new(), + client_causal_claims: Vec::new(), + correlation_handoff: crate::sccm::server::windows::SccmSoftwareUpdatePointCorrelationHandoff { + issue: "#333".to_owned(), + performed: false, + time_only_eligible: false, + }, + }; + + let before = [ + serde_json::to_vec(&policy).unwrap(), + serde_json::to_vec(&management_point).unwrap(), + serde_json::to_vec(&deployment).unwrap(), + serde_json::to_vec(&distribution_point).unwrap(), + serde_json::to_vec(&updates).unwrap(), + serde_json::to_vec(&software_update_point).unwrap(), + ]; + correlate_policy_management_point(&SccmPolicyManagementPointInput::from_analyses( + &policy, + &management_point, + )); + correlate_content_distribution_point(&SccmContentDistributionPointInput::from_analyses( + &deployment, + &distribution_point, + )); + correlate_updates_software_update_point(&SccmUpdatesSoftwareUpdatePointInput::from_analyses( + &updates, + &software_update_point, + )); + let after = [ + serde_json::to_vec(&policy).unwrap(), + serde_json::to_vec(&management_point).unwrap(), + serde_json::to_vec(&deployment).unwrap(), + serde_json::to_vec(&distribution_point).unwrap(), + serde_json::to_vec(&updates).unwrap(), + serde_json::to_vec(&software_update_point).unwrap(), + ]; + assert_eq!(before, after); +} + +#[test] +fn policy_management_point_matrix_is_an_exact_production_oracle() { + run_oracle_matrix("policy_management_point/adversarial-matrix.json"); +} + +#[test] +fn content_distribution_point_matrix_is_an_exact_production_oracle() { + run_oracle_matrix("content_distribution_point/adversarial-matrix.json"); +} + +#[test] +fn updates_software_update_point_matrix_is_an_exact_production_oracle() { + run_oracle_matrix("updates_software_update_point/adversarial-matrix.json"); +} diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs new file mode 100644 index 000000000..1a37dac9b --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -0,0 +1,473 @@ +use crate::parser::ccm::{CcmLogicalRecord, CcmTimestampParse, CcmTimestampParseState}; +use regex::Regex; +use std::sync::OnceLock; + +use super::models::{ + SccmArtifact, SccmEvidence, SccmEvidenceRef, SccmRole, SccmTimeOrderingState, SccmTimestamp, +}; + +const PUBLIC_MESSAGE_PROFILE: &str = "sccm-public-message-v1"; +const PUBLIC_MESSAGE_REDACTION: &str = "[redacted:sccm-public-message-v1]"; + +fn sensitive_message_label_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new( + r#"(?ix) + (?: + ["']?\b authorization(?:[\x20_-]?(?:header|token))?\b ["']? + (?:[\x20\t]*[:=][\x20\t]*|[\x20\t]+) + (?:[a-z][a-z0-9._-]*[\x20\t]+)? + | + ["']?\b bearer\b ["']? + (?:[\x20\t]*[:=][\x20\t]*|[\x20\t]+) + | + ["']?\b(?: + shared[\x20_-]?access[\x20_-]?signature + | client[\x20_-]?secret + | (?:client|access|refresh|id|device|session)[\x20_-]?token + | api[\x20_-]?key + | account[\x20_-]?key + | samaccountname + | accountname + | callerhandle + | localuser + | identity + | queryhandle + | user[\x20_-]?principal[\x20_-]?name + | credential + | password + | passwd + | secret + | token + | username + | user + | upn + | sig + )\b["']?(?:[\x20\t]*[:=][\x20\t]*|[\x20\t]+) + )"#, + ) + .expect("SCCM sensitive message label regex must compile") + }) +} + +fn windows_identity_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new( + r"(?i)(?:\b(?:NT AUTHORITY|[A-Z0-9][A-Z0-9._-]*)|\.)(?:\\)+[A-Z0-9][A-Z0-9._$-]*\b", + ) + .expect("SCCM Windows identity regex must compile") + }) +} + +fn windows_user_path_identity_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new( + r"(?ix) + (?:^|[\\/]+) + (?:users|profiles|home|documents[\x20_-]+and[\x20_-]+settings) + [\\/]+ + (?P + (?:(?:NT[\x20]+AUTHORITY|[A-Z0-9][A-Z0-9._-]*|\.)[\\/]+)? + [A-Z0-9][A-Z0-9._$-]*\b + )", + ) + .expect("SCCM Windows user-path identity regex must compile") + }) +} + +fn email_identity_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"(?i)\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b") + .expect("SCCM email identity regex must compile") + }) +} + +/// Profile v1 is a deterministic, parser-only public projection. It removes +/// recognized identity and secret-bearing values while leaving diagnostic +/// codes and approved structured keys outside those values unchanged. It does +/// not imply native collector validation. +fn project_public_message_v1(raw: &str) -> String { + format!("[{PUBLIC_MESSAGE_PROFILE}] {}", project_public_text_v1(raw)) +} + +fn project_public_text_v1(raw: &str) -> String { + let redacted = redact_sensitive_segments(raw); + let identities_redacted = redact_windows_identities(&redacted); + redact_email_identities(&identities_redacted) +} + +fn redact_sensitive_segments(value: &str) -> String { + let mut projected = String::with_capacity(value.len()); + let mut copied_through = 0; + let mut search_from = 0; + + while let Some(label) = sensitive_message_label_re().find_at(value, search_from) { + projected.push_str(&value[copied_through..label.start()]); + projected.push_str(PUBLIC_MESSAGE_REDACTION); + + let value_end = if is_provider_private_label(label.as_str()) { + provider_private_value_end(value, label.end()) + } else { + sensitive_value_end(value, label.end()) + }; + copied_through = value_end; + search_from = value_end; + } + + projected.push_str(&value[copied_through..]); + projected +} + +fn is_provider_private_label(label: &str) -> bool { + let label = label.to_ascii_lowercase(); + label.contains("authorization") + || label.contains("callerhandle") + || label.contains("queryhandle") +} + +fn provider_private_value_end(value: &str, value_start: usize) -> usize { + let remaining = &value[value_start..]; + if remaining + .chars() + .next() + .is_some_and(|first| matches!(first, '"' | '\'')) + { + return sensitive_value_end(value, value_start); + } + + let line_end = remaining + .char_indices() + .find_map(|(offset, character)| { + matches!(character, '\r' | '\n').then_some(value_start + offset) + }) + .unwrap_or(value.len()); + let private_value = &value[value_start..line_end]; + + private_value + .match_indices(';') + .find_map(|(offset, delimiter)| { + let tail_start = offset + delimiter.len(); + provider_tail_has_independent_boundary(&private_value[tail_start..]) + .then_some(value_start + offset) + }) + .unwrap_or(line_end) +} + +fn provider_tail_has_independent_boundary(value: &str) -> bool { + let trimmed = value.trim_start(); + provider_public_tail_is_safe(trimmed) + || sensitive_message_label_re() + .find(trimmed) + .is_some_and(|label| label.start() == 0) +} + +fn provider_public_tail_is_safe(value: &str) -> bool { + let mut segments = value + .split(';') + .map(str::trim) + .filter(|part| !part.is_empty()); + let Some(first) = segments.next() else { + return false; + }; + + std::iter::once(first).chain(segments).all(|segment| { + let Some((label, value)) = segment.split_once('=') else { + return false; + }; + let label = label.trim(); + let value = value.trim(); + matches!( + label.to_ascii_lowercase().as_str(), + "phase" + | "disposition" + | "terminal" + | "requestid" + | "operationhandle" + | "endpointid" + | "layer" + | "profileid" + | "status" + | "result" + | "errorcode" + | "hresult" + ) && !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'.' | b'_' | b'-' | b'{' | b'}' | b':' | b'+') + }) + }) +} + +fn sensitive_value_end(value: &str, value_start: usize) -> usize { + let remaining = &value[value_start..]; + let Some(first) = remaining.chars().next() else { + return value.len(); + }; + + if matches!(first, '"' | '\'') { + let quote_width = first.len_utf8(); + let mut escaped = false; + for (offset, character) in remaining[quote_width..].char_indices() { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == first { + return value_start + quote_width + offset + character.len_utf8(); + } + } + + return value.len(); + } + + remaining + .char_indices() + .find_map(|(offset, character)| { + (character.is_whitespace() || matches!(character, ',' | ';' | '&')) + .then_some(value_start + offset) + }) + .unwrap_or(value.len()) +} + +fn redact_windows_identities(value: &str) -> String { + let mut projected = String::with_capacity(value.len()); + let mut copied_through = 0; + let mut identity_ranges = windows_identity_re() + .find_iter(value) + .filter_map(|matched| { + let preceding = value[..matched.start()].chars().next_back(); + let following = value[matched.end()..].chars().next(); + let begins_relative_path = + matched.as_str().starts_with(r".\") && matches!(following, Some('\\' | '/')); + (!matches!(preceding, Some('\\' | '/')) && !begins_relative_path) + .then_some((matched.start(), matched.end())) + }) + .collect::>(); + identity_ranges.extend( + windows_user_path_identity_re() + .captures_iter(value) + .filter_map(|captures| { + captures + .name("identity") + .map(|matched| (matched.start(), matched.end())) + }), + ); + identity_ranges.sort_unstable(); + identity_ranges.dedup(); + + let mut merged_ranges: Vec<(usize, usize)> = Vec::with_capacity(identity_ranges.len()); + for (start, end) in identity_ranges { + if let Some((_, merged_end)) = merged_ranges.last_mut() { + if start <= *merged_end { + *merged_end = (*merged_end).max(end); + continue; + } + } + merged_ranges.push((start, end)); + } + + for (start, end) in merged_ranges { + projected.push_str(&value[copied_through..start]); + projected.push_str(PUBLIC_MESSAGE_REDACTION); + copied_through = end; + } + + projected.push_str(&value[copied_through..]); + projected +} + +fn redact_email_identities(value: &str) -> String { + email_identity_re() + .replace_all(value, PUBLIC_MESSAGE_REDACTION) + .into_owned() +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SccmRawEvidenceSnapshot { + evidence_id: String, + reference: SccmEvidenceRef, + role: SccmRole, + component: Option, + ccm_source_file: Option, + message: String, + timestamp: SccmTimestamp, + raw_execution_context: Option, +} + +impl SccmRawEvidenceSnapshot { + pub(crate) fn from_record(artifact: &SccmArtifact, record: CcmLogicalRecord) -> Self { + let CcmLogicalRecord { + entry, + context, + line_start, + line_end, + timestamp, + } = record; + let entry_id = format!("{}:{line_start}-{line_end}", artifact.artifact_id); + + Self { + evidence_id: entry_id.clone(), + reference: SccmEvidenceRef { + artifact_id: artifact.artifact_id.clone(), + entry_id, + line_start: Some(line_start), + line_end: Some(line_end), + }, + role: artifact.role.clone(), + component: entry.component, + ccm_source_file: entry.source_file, + message: entry.message, + timestamp: timestamp.into(), + raw_execution_context: context, + } + } + + pub(crate) fn export(&self) -> SccmEvidence { + SccmEvidence { + evidence_id: self.evidence_id.clone(), + reference: self.reference.clone(), + role: self.role.clone(), + component: self.component.as_deref().map(project_public_text_v1), + ccm_source_file: self.ccm_source_file.as_deref().map(project_public_text_v1), + message: project_public_message_v1(&self.message), + timestamp: self.timestamp.clone(), + // Raw execution context remains available only to this + // crate-private snapshot. A public handle requires a separately + // reviewed keyed scheme and explicit caller-provided key. + execution_context: None, + } + } +} + +impl From for SccmTimestamp { + fn from(timestamp: CcmTimestampParse) -> Self { + Self { + original_display: timestamp.original_display, + offset_minutes: timestamp.offset_minutes, + utc_millis: timestamp.utc_millis, + ordering_state: timestamp.ordering_state.into(), + } + } +} + +impl From for SccmTimeOrderingState { + fn from(state: CcmTimestampParseState) -> Self { + match state { + CcmTimestampParseState::NormalizedUtc => Self::NormalizedUtc, + CcmTimestampParseState::OffsetMissing => Self::OffsetMissing, + CcmTimestampParseState::OffsetInvalid => Self::OffsetInvalid, + CcmTimestampParseState::TimestampMissing => Self::TimestampMissing, + } + } +} + +#[cfg(test)] +mod tests { + use crate::parser::ccm::scan_logical_records; + + use super::*; + use crate::sccm::models::{SccmCoverageState, SccmRole, SccmRotation}; + + #[test] + fn export_redaction_does_not_mutate_raw_snapshot() { + let raw_message = r#"Policy id={ABCDEFAB-0000-0000-0000-000000000001} failed hr=0x80070005 user=LAB\SyntheticUser token=synthetic-secret payload={"token":"synthetic-json-secret","user":"SyntheticJsonUser"} url=https://example.invalid/?token=synthetic-query-secret&status=71"#; + let text = format!( + r#""# + ); + let artifact = SccmArtifact { + artifact_id: "client-policy-agent".into(), + display_name: "PolicyAgent.log".into(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: None, + collected_at_utc: None, + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".into()), + }; + let record = scan_logical_records(&text, &artifact.display_name) + .into_iter() + .next() + .expect("fixture contains one CCM record"); + let snapshot = SccmRawEvidenceSnapshot::from_record(&artifact, record); + let before = snapshot.clone(); + + let exported = snapshot.export(); + + assert_eq!(snapshot, before); + assert_eq!( + snapshot.raw_execution_context.as_deref(), + Some(r"NT AUTHORITY\SYSTEM") + ); + assert_eq!(snapshot.message, raw_message); + let exported_json = serde_json::to_string(&exported).unwrap(); + assert!(!exported_json.contains(r"NT AUTHORITY\\SYSTEM")); + assert!(!exported_json.contains(r"LAB\\SyntheticUser")); + assert!(!exported_json.contains("synthetic-secret")); + assert!(!exported_json.contains("synthetic-json-secret")); + assert!(!exported_json.contains("SyntheticJsonUser")); + assert!(!exported_json.contains("synthetic-query-secret")); + assert!(exported.message.starts_with("[sccm-public-message-v1] ")); + assert!(exported.message.contains("hr=0x80070005")); + assert!(exported.message.contains("&status=71")); + assert!(exported + .message + .contains("{ABCDEFAB-0000-0000-0000-000000000001}")); + assert_eq!(exported.execution_context, None); + } + + #[test] + fn export_merges_overlapping_identity_ranges_without_mutating_raw_snapshot() { + let raw_identity = r"users\ADMIN\secret"; + let raw_message = format!("{raw_identity}; status=71"); + let text = format!( + r#""# + ); + let artifact = SccmArtifact { + artifact_id: "client-policy-agent".into(), + display_name: "PolicyAgent.log".into(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: None, + collected_at_utc: None, + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".into()), + }; + let record = scan_logical_records(&text, &artifact.display_name) + .into_iter() + .next() + .expect("fixture contains one CCM record"); + let snapshot = SccmRawEvidenceSnapshot::from_record(&artifact, record); + let before = snapshot.clone(); + + let exported = snapshot.export(); + let exported_json = serde_json::to_string(&exported).unwrap(); + + assert_eq!(snapshot, before); + assert!(snapshot.message.contains(raw_identity)); + assert_eq!(snapshot.component.as_deref(), Some(raw_identity)); + assert_eq!(snapshot.ccm_source_file.as_deref(), Some(raw_identity)); + for private_segment in ["ADMIN", "secret"] { + assert!( + !exported_json.contains(private_segment), + "{private_segment} leaked after overlapping identity projection" + ); + } + assert!(exported.message.contains("status=71")); + assert!(exported + .component + .as_deref() + .is_some_and(|value| value.contains(PUBLIC_MESSAGE_REDACTION))); + assert!(exported + .ccm_source_file + .as_deref() + .is_some_and(|value| value.contains(PUBLIC_MESSAGE_REDACTION))); + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs new file mode 100644 index 000000000..7feb8c245 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -0,0 +1,2719 @@ +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::slice; + +use serde::de::Error as _; +use serde::ser::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::models::log_entry::Severity; + +use super::catalog::declared_source_catalog; +use super::keys::{normalize_key, SCCM_HIERARCHY_KEY_PROFILE_ID}; +use super::models::{ + SccmCorrelationKey, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidenceRef, + SccmFindingClass, SccmKeyConfidence, SccmRole, +}; + +pub const MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS: usize = 240; +pub const MAX_SCCM_NEXT_ARTIFACT_REQUESTS: usize = 16; +// Shared wire bound for every opaque identifier a finding carries: finding +// ids, evidence artifact and entry ids, coverage-gap artifact ids, request +// logical ids, and extraction profile ids. Enforced inside +// is_canonical_opaque_id so no identifier path can skip it. +const MAX_SCCM_OPAQUE_ID_CHARS: usize = 256; +// Single-line display heading; twice the opaque id bound covers generated +// " " headings without admitting unbounded text. +const MAX_SCCM_FINDING_TITLE_CHARS: usize = 512; +// Multi-sentence display paragraph shown in the finding detail pane. +const MAX_SCCM_FINDING_SUMMARY_CHARS: usize = 2048; +// Correlation-key raw and normalized values. The widest canonical form is a +// 253-character server-host FQDN; 256 leaves room for braces and prefixes +// while excluding unbounded decimal ids. +// Do not lower this. normalize_server_host self-caps a host at 253 chars, so +// a worst-case ServerHost key normalizes to 254 with a trailing-dot source +// against this 256 limit, roughly 99% of the bound and the tightest headroom +// any bound in this module carries. +// Shared with the extraction producer in keys.rs so extract_keys cannot emit a +// key this validator would reject. Crate-visible rather than public: it is an +// internal agreement between the producer and the validator, not wire surface. +pub(crate) const MAX_SCCM_CORRELATION_KEY_VALUE_CHARS: usize = 256; +// The sole registered profile is a closed synthetic-fixture contract. Its +// registration authorizes exact fixture keys, not any production ConfigMgr +// version. Adding a production profile requires separate contract review. +const REGISTERED_STABLE_CORRELATION_PROFILE_IDS: &[&str] = + &["policy-client-5.00.test-v1", SCCM_HIERARCHY_KEY_PROFILE_ID]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmConfidence { + None, + Low, + Moderate, + High, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SccmPhase { + Policy, + Content, + Enforcement, + Unknown(String), +} + +impl SccmPhase { + fn serialized_name(&self) -> &str { + match self { + Self::Policy => "policy", + Self::Content => "content", + Self::Enforcement => "enforcement", + Self::Unknown(value) => value, + } + } + + fn has_canonical_serialized_form(&self) -> bool { + match self { + Self::Unknown(value) => { + !value.is_empty() && value.trim() == value && !is_known_phase_name(value) + } + _ => true, + } + } +} + +impl Serialize for SccmPhase { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if !self.has_canonical_serialized_form() { + return Err(S::Error::custom( + "unknown SCCM phase must be canonical and must not shadow a declared phase", + )); + } + serializer.serialize_str(self.serialized_name()) + } +} + +impl<'de> Deserialize<'de> for SccmPhase { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let phase = match String::deserialize(deserializer)? { + value if value == "policy" => Self::Policy, + value if value == "content" => Self::Content, + value if value == "enforcement" => Self::Enforcement, + value => Self::Unknown(value), + }; + if !phase.has_canonical_serialized_form() { + return Err(D::Error::custom( + "unknown SCCM phase must be canonical and must not shadow a declared phase", + )); + } + Ok(phase) + } +} + +fn is_known_phase_name(value: &str) -> bool { + matches!(value, "policy" | "content" | "enforcement") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SccmTerminalEvidenceKind { + ObservedFailure, + Unknown(String), +} + +impl SccmTerminalEvidenceKind { + fn serialized_name(&self) -> &str { + match self { + Self::ObservedFailure => "observedFailure", + Self::Unknown(value) => value, + } + } + + fn has_canonical_serialized_form(&self) -> bool { + match self { + Self::Unknown(value) => { + !value.is_empty() && value.trim() == value && value != "observedFailure" + } + Self::ObservedFailure => true, + } + } +} + +impl Serialize for SccmTerminalEvidenceKind { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if !self.has_canonical_serialized_form() { + return Err(S::Error::custom( + "unknown terminal evidence kind must be canonical and must not shadow observedFailure", + )); + } + serializer.serialize_str(self.serialized_name()) + } +} + +impl<'de> Deserialize<'de> for SccmTerminalEvidenceKind { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let kind = match String::deserialize(deserializer)? { + value if value == "observedFailure" => Self::ObservedFailure, + value => Self::Unknown(value), + }; + if !kind.has_canonical_serialized_form() { + return Err(D::Error::custom( + "unknown terminal evidence kind must be canonical and must not shadow observedFailure", + )); + } + Ok(kind) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SccmTerminalEvidence { + pub reference: SccmEvidenceRef, + pub kind: SccmTerminalEvidenceKind, +} + +impl SccmTerminalEvidence { + pub fn observed_failure(reference: SccmEvidenceRef) -> Self { + Self { + reference, + kind: SccmTerminalEvidenceKind::ObservedFailure, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmFindingCoverageGap { + pub artifact_id: String, + pub role: SccmRole, + pub coverage: SccmCoverageState, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmArtifactRequest { + pub logical_id: String, + pub role: SccmRole, + pub reason: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SccmArtifactRequestSerializeWire<'a> { + logical_id: &'a str, + role: &'a SccmRole, + reason: &'a str, +} + +impl Serialize for SccmArtifactRequest { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + validate_artifact_requests(slice::from_ref(self)).map_err(|error| { + S::Error::custom(format!("invalid SCCM artifact request contract: {error:?}")) + })?; + let reason = self.reason.trim(); + SccmArtifactRequestSerializeWire { + logical_id: &self.logical_id, + role: &self.role, + reason, + } + .serialize(serializer) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmFinding { + pub finding_id: String, + pub class: SccmFindingClass, + pub phase: SccmPhase, + pub role: SccmRole, + pub severity: Severity, + pub confidence: SccmConfidence, + pub title: String, + pub summary: String, + pub evidence: Vec, + pub terminal_evidence: Vec, + pub coverage_gaps: Vec, + pub correlation_keys: Vec, + pub next_artifacts: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SccmFindingSerializeWire<'a> { + finding_id: &'a str, + class: &'a SccmFindingClass, + phase: &'a SccmPhase, + role: &'a SccmRole, + severity: &'a Severity, + confidence: &'a SccmConfidence, + title: &'a str, + summary: &'a str, + evidence: &'a [SccmEvidenceRef], + terminal_evidence: &'a [SccmTerminalEvidence], + coverage_gaps: &'a [SccmFindingCoverageGap], + correlation_keys: &'a [SccmCorrelationKey], + next_artifacts: &'a [SccmArtifactRequest], +} + +impl Serialize for SccmFinding { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.validate().map_err(|error| { + S::Error::custom(format!("invalid SCCM finding contract: {error:?}")) + })?; + let mut normalized = self.clone(); + normalize_finding(&mut normalized); + normalized.validate().map_err(|error| { + S::Error::custom(format!("invalid SCCM finding contract: {error:?}")) + })?; + SccmFindingSerializeWire { + finding_id: &normalized.finding_id, + class: &normalized.class, + phase: &normalized.phase, + role: &normalized.role, + severity: &normalized.severity, + confidence: &normalized.confidence, + title: &normalized.title, + summary: &normalized.summary, + evidence: &normalized.evidence, + terminal_evidence: &normalized.terminal_evidence, + coverage_gaps: &normalized.coverage_gaps, + correlation_keys: &normalized.correlation_keys, + next_artifacts: &normalized.next_artifacts, + } + .serialize(serializer) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmFindingWire { + finding_id: String, + class: SccmFindingClass, + phase: SccmPhase, + role: SccmRole, + severity: Severity, + confidence: SccmConfidence, + title: String, + summary: String, + evidence: Vec, + terminal_evidence: Vec, + coverage_gaps: Vec, + correlation_keys: Vec, + next_artifacts: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SccmEvidenceRefSerializeWire<'a> { + artifact_id: &'a str, + entry_id: &'a str, + line_start: Option, + line_end: Option, +} + +impl Serialize for SccmEvidenceRef { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + validate_evidence_reference(self).map_err(|error| { + S::Error::custom(format!( + "invalid SCCM evidence reference contract: {error:?}" + )) + })?; + SccmEvidenceRefSerializeWire { + artifact_id: &self.artifact_id, + entry_id: &self.entry_id, + line_start: self.line_start, + line_end: self.line_end, + } + .serialize(serializer) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmEvidenceRefWire { + artifact_id: String, + entry_id: String, + line_start: Option, + line_end: Option, +} + +impl From for SccmEvidenceRef { + fn from(wire: SccmEvidenceRefWire) -> Self { + Self { + artifact_id: wire.artifact_id, + entry_id: wire.entry_id, + line_start: wire.line_start, + line_end: wire.line_end, + } + } +} + +// SccmEvidenceRef is declared in models.rs, but its wire contract belongs +// here next to the validator it must satisfy. Every reference the crate +// accepts, whether standalone or nested in SccmEvidence or an extraction gap, +// clears the same bar a finding's own citations clear. +impl<'de> Deserialize<'de> for SccmEvidenceRef { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let reference = Self::from(SccmEvidenceRefWire::deserialize(deserializer)?); + validate_evidence_reference(&reference).map_err(|error| { + D::Error::custom(format!( + "invalid SCCM evidence reference contract: {error:?}" + )) + })?; + Ok(reference) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SccmTerminalEvidenceSerializeWire<'a> { + reference: &'a SccmEvidenceRef, + kind: &'a SccmTerminalEvidenceKind, +} + +impl Serialize for SccmTerminalEvidence { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + validate_evidence_reference(&self.reference) + .and_then(|()| { + validate_terminal_evidence(slice::from_ref(&self.reference), slice::from_ref(self)) + }) + .map_err(|error| { + S::Error::custom(format!( + "invalid SCCM terminal evidence contract: {error:?}" + )) + })?; + SccmTerminalEvidenceSerializeWire { + reference: &self.reference, + kind: &self.kind, + } + .serialize(serializer) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmTerminalEvidenceWire { + reference: SccmEvidenceRefWire, + kind: SccmTerminalEvidenceKind, +} + +impl From for SccmTerminalEvidence { + fn from(wire: SccmTerminalEvidenceWire) -> Self { + Self { + reference: wire.reference.into(), + kind: wire.kind, + } + } +} + +// A standalone terminal evidence has no surrounding finding to cite, so it +// stands as its own citation set. That trivially satisfies the "must be cited" +// rule while still enforcing the kind gate: only observedFailure is terminal. +impl<'de> Deserialize<'de> for SccmTerminalEvidence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let terminal = Self::from(SccmTerminalEvidenceWire::deserialize(deserializer)?); + validate_evidence_reference(&terminal.reference) + .and_then(|()| { + validate_terminal_evidence( + slice::from_ref(&terminal.reference), + slice::from_ref(&terminal), + ) + }) + .map_err(|error| { + D::Error::custom(format!( + "invalid SCCM terminal evidence contract: {error:?}" + )) + })?; + Ok(terminal) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SccmFindingCoverageGapSerializeWire<'a> { + artifact_id: &'a str, + role: &'a SccmRole, + coverage: &'a SccmCoverageState, +} + +impl Serialize for SccmFindingCoverageGap { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + validate_coverage_gaps(slice::from_ref(self)).map_err(|error| { + S::Error::custom(format!("invalid SCCM coverage gap contract: {error:?}")) + })?; + SccmFindingCoverageGapSerializeWire { + artifact_id: &self.artifact_id, + role: &self.role, + coverage: &self.coverage, + } + .serialize(serializer) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmFindingCoverageGapWire { + artifact_id: String, + role: SccmRole, + coverage: SccmCoverageState, +} + +impl From for SccmFindingCoverageGap { + fn from(wire: SccmFindingCoverageGapWire) -> Self { + Self { + artifact_id: wire.artifact_id, + role: wire.role, + coverage: wire.coverage, + } + } +} + +// A coverage gap deserialized on its own must clear the same bar it clears +// as a member of SccmFinding::coverage_gaps, so route it through the same +// deny_unknown_fields wire struct and the same validator. +impl<'de> Deserialize<'de> for SccmFindingCoverageGap { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let gap = Self::from(SccmFindingCoverageGapWire::deserialize(deserializer)?); + validate_coverage_gaps(slice::from_ref(&gap)).map_err(|error| { + D::Error::custom(format!("invalid SCCM coverage gap contract: {error:?}")) + })?; + Ok(gap) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SccmCorrelationKeySerializeWire<'a> { + kind: &'a SccmCorrelationKeyKind, + raw: &'a str, + normalized: &'a str, + confidence: &'a SccmKeyConfidence, + extraction_profile_id: Option<&'a str>, + evidence: Option<&'a SccmEvidenceRef>, + start: Option, + end: Option, +} + +impl Serialize for SccmCorrelationKey { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + validate_correlation_key_evidence(self.evidence.as_slice(), slice::from_ref(self)) + .map_err(|error| { + S::Error::custom(format!("invalid SCCM correlation key contract: {error:?}")) + })?; + SccmCorrelationKeySerializeWire { + kind: &self.kind, + raw: &self.raw, + normalized: &self.normalized, + confidence: &self.confidence, + extraction_profile_id: self.extraction_profile_id.as_deref(), + evidence: self.evidence.as_ref(), + start: self.start, + end: self.end, + } + .serialize(serializer) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmCorrelationKeyWire { + kind: SccmCorrelationKeyKind, + raw: String, + normalized: String, + confidence: SccmKeyConfidence, + extraction_profile_id: Option, + evidence: Option, + start: Option, + end: Option, +} + +impl From for SccmCorrelationKey { + fn from(wire: SccmCorrelationKeyWire) -> Self { + Self { + kind: wire.kind, + raw: wire.raw, + normalized: wire.normalized, + confidence: wire.confidence, + extraction_profile_id: wire.extraction_profile_id, + evidence: wire.evidence.map(Into::into), + start: wire.start, + end: wire.end, + } + } +} + +// The key's own evidence reference stands in as its citation set, exactly as +// it would inside a finding. Containment is therefore self-satisfying here, so +// validate_correlation_key_evidence validates the reference itself before it +// checks containment; the wire struct nests SccmEvidenceRefWire and cannot +// rely on the SccmEvidenceRef deserializer to do it. Crucially this keeps the +// confidence gate in force: while REGISTERED_STABLE_CORRELATION_PROFILE_IDS is +// empty, no payload can deserialize a Strong or Exact key and forge +// corroboration strength. +impl<'de> Deserialize<'de> for SccmCorrelationKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let key = Self::from(SccmCorrelationKeyWire::deserialize(deserializer)?); + let citations = key.evidence.as_slice(); + validate_correlation_key_evidence(citations, slice::from_ref(&key)).map_err(|error| { + D::Error::custom(format!("invalid SCCM correlation key contract: {error:?}")) + })?; + Ok(key) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmArtifactRequestWire { + logical_id: String, + role: SccmRole, + reason: String, +} + +impl From for SccmArtifactRequest { + fn from(wire: SccmArtifactRequestWire) -> Self { + Self { + logical_id: wire.logical_id, + role: wire.role, + reason: wire.reason, + } + } +} + +// Same contract as a request carried inside SccmFinding::next_artifacts: the +// logical id must name a declared catalog source for the requested role and +// the reason must stay bounded and scoped to that artifact. +impl<'de> Deserialize<'de> for SccmArtifactRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let mut request = Self::from(SccmArtifactRequestWire::deserialize(deserializer)?); + validate_artifact_requests(slice::from_ref(&request)).map_err(|error| { + D::Error::custom(format!("invalid SCCM artifact request contract: {error:?}")) + })?; + request.reason = request.reason.trim().to_owned(); + Ok(request) + } +} + +impl<'de> Deserialize<'de> for SccmFinding { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = SccmFindingWire::deserialize(deserializer)?; + if wire.next_artifacts.len() > MAX_SCCM_NEXT_ARTIFACT_REQUESTS { + return Err(D::Error::custom("too many SCCM artifact requests")); + } + let mut finding = Self { + finding_id: wire.finding_id, + class: wire.class, + phase: wire.phase, + role: wire.role, + severity: wire.severity, + confidence: wire.confidence, + title: wire.title, + summary: wire.summary, + evidence: wire.evidence.into_iter().map(Into::into).collect(), + terminal_evidence: wire.terminal_evidence.into_iter().map(Into::into).collect(), + coverage_gaps: wire.coverage_gaps.into_iter().map(Into::into).collect(), + correlation_keys: wire.correlation_keys.into_iter().map(Into::into).collect(), + next_artifacts: wire.next_artifacts.into_iter().map(Into::into).collect(), + }; + validate_raw_text_bounds(&finding).map_err(|error| { + D::Error::custom(format!("invalid SCCM finding contract: {error:?}")) + })?; + normalize_finding(&mut finding); + finding.validate().map_err(|error| { + D::Error::custom(format!("invalid SCCM finding contract: {error:?}")) + })?; + Ok(finding) + } +} + +impl SccmFinding { + pub fn validate(&self) -> Result<(), SccmFindingValidationError> { + validate_raw_text_bounds(self)?; + validate_required_text(self)?; + validate_roles(self)?; + validate_all_evidence_references(self)?; + validate_coverage_gaps(&self.coverage_gaps)?; + validate_artifact_requests(&self.next_artifacts)?; + + if self.class == SccmFindingClass::InsufficientEvidence && self.coverage_gaps.is_empty() { + return Err(SccmFindingValidationError::MissingCoverageGap); + } + + if self.evidence.is_empty() + && (self.coverage_gaps.is_empty() + || self.class != SccmFindingClass::InsufficientEvidence) + { + return Err(SccmFindingValidationError::MissingEvidenceOrCoverageGap); + } + + validate_terminal_evidence(&self.evidence, &self.terminal_evidence)?; + validate_correlation_key_evidence(&self.evidence, &self.correlation_keys)?; + + if self.class == SccmFindingClass::InsufficientEvidence && self.next_artifacts.is_empty() { + return Err(SccmFindingValidationError::MissingNextArtifactRequest); + } + + let has_terminal_failure = self + .terminal_evidence + .iter() + .any(|terminal| terminal.kind == SccmTerminalEvidenceKind::ObservedFailure); + + if self.class == SccmFindingClass::ConfirmedFailure + && self.confidence == SccmConfidence::High + && !has_terminal_failure + && !has_profiled_key_corroboration(&self.correlation_keys) + { + return Err(SccmFindingValidationError::MissingTerminalEvidence); + } + + if self.class == SccmFindingClass::LikelyContributor + && self.confidence == SccmConfidence::High + && !has_terminal_failure + { + return Err(SccmFindingValidationError::LikelyContributorConfidenceTooHigh); + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SccmFindingValidationError { + MissingRequiredField, + InvalidRole, + InvalidEvidenceReference, + ConflictingEvidenceReference, + OverlappingEvidenceReference, + MissingEvidenceOrCoverageGap, + MissingTerminalEvidence, + InvalidTerminalEvidence, + TerminalEvidenceNotCited, + CorrelationKeyMissingEvidence, + CorrelationKeyEvidenceNotCited, + InvalidCorrelationKey, + LikelyContributorConfidenceTooHigh, + MissingCoverageGap, + InvalidCoverageGap, + MissingNextArtifactRequest, + UndeclaredArtifactRequest, + ArtifactRequestRoleMismatch, + InvalidArtifactRequestReason, + TooManyArtifactRequests, +} + +#[derive(Debug, Clone)] +pub struct SccmFindingBuilder { + finding_id: String, + class: Option, + phase: Option, + role: Option, + severity: Option, + confidence: Option, + title: String, + summary: String, + evidence: Vec, + terminal_evidence: Vec, + coverage_gaps: Vec, + correlation_keys: Vec, + next_artifacts: Vec, +} + +impl SccmFindingBuilder { + pub fn new(finding_id: impl Into) -> Self { + let finding_id = finding_id.into(); + Self { + title: finding_id.clone(), + summary: finding_id.clone(), + finding_id, + class: None, + phase: None, + role: None, + severity: None, + confidence: None, + evidence: Vec::new(), + terminal_evidence: Vec::new(), + coverage_gaps: Vec::new(), + correlation_keys: Vec::new(), + next_artifacts: Vec::new(), + } + } + + pub fn class(mut self, class: SccmFindingClass) -> Self { + self.class = Some(class); + self + } + + pub fn phase(mut self, phase: SccmPhase) -> Self { + self.phase = Some(phase); + self + } + + pub fn role(mut self, role: SccmRole) -> Self { + self.role = Some(role); + self + } + + pub fn severity(mut self, severity: Severity) -> Self { + self.severity = Some(severity); + self + } + + pub fn confidence(mut self, confidence: SccmConfidence) -> Self { + self.confidence = Some(confidence); + self + } + + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + pub fn summary(mut self, summary: impl Into) -> Self { + self.summary = summary.into(); + self + } + + pub fn evidence(mut self, evidence: Vec) -> Self { + self.evidence = evidence; + self + } + + pub fn terminal_evidence(mut self, terminal_evidence: Vec) -> Self { + self.terminal_evidence = terminal_evidence; + self + } + + pub fn coverage_gap(mut self, coverage_gap: SccmFindingCoverageGap) -> Self { + self.coverage_gaps.push(coverage_gap); + self + } + + pub fn coverage_gaps(mut self, coverage_gaps: Vec) -> Self { + self.coverage_gaps = coverage_gaps; + self + } + + pub fn correlation_keys(mut self, correlation_keys: Vec) -> Self { + self.correlation_keys = correlation_keys; + self + } + + pub fn next_artifact(mut self, next_artifact: SccmArtifactRequest) -> Self { + self.next_artifacts.push(next_artifact); + self + } + + pub fn next_artifacts(mut self, next_artifacts: Vec) -> Self { + self.next_artifacts = next_artifacts; + self + } + + pub fn build(self) -> Result { + if self.next_artifacts.len() > MAX_SCCM_NEXT_ARTIFACT_REQUESTS { + return Err(SccmFindingValidationError::TooManyArtifactRequests); + } + + let mut finding = SccmFinding { + finding_id: self.finding_id, + class: self + .class + .ok_or(SccmFindingValidationError::MissingRequiredField)?, + phase: self + .phase + .ok_or(SccmFindingValidationError::MissingRequiredField)?, + role: self + .role + .ok_or(SccmFindingValidationError::MissingRequiredField)?, + severity: self + .severity + .ok_or(SccmFindingValidationError::MissingRequiredField)?, + confidence: self + .confidence + .ok_or(SccmFindingValidationError::MissingRequiredField)?, + title: self.title, + summary: self.summary, + evidence: self.evidence, + terminal_evidence: self.terminal_evidence, + coverage_gaps: self.coverage_gaps, + correlation_keys: self.correlation_keys, + next_artifacts: self.next_artifacts, + }; + validate_raw_text_bounds(&finding)?; + normalize_finding(&mut finding); + finding.validate()?; + Ok(finding) + } +} + +fn validate_raw_text_bounds(finding: &SccmFinding) -> Result<(), SccmFindingValidationError> { + if !has_at_most_chars(&finding.title, MAX_SCCM_FINDING_TITLE_CHARS) + || !has_at_most_chars(&finding.summary, MAX_SCCM_FINDING_SUMMARY_CHARS) + { + return Err(SccmFindingValidationError::MissingRequiredField); + } + if finding + .next_artifacts + .iter() + .any(|request| !has_at_most_chars(&request.reason, MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS)) + { + return Err(SccmFindingValidationError::InvalidArtifactRequestReason); + } + Ok(()) +} + +fn validate_required_text(finding: &SccmFinding) -> Result<(), SccmFindingValidationError> { + let title = finding.title.trim(); + let summary = finding.summary.trim(); + if !is_canonical_opaque_id(&finding.finding_id) + || title.is_empty() + || summary.is_empty() + || !finding.phase.has_canonical_serialized_form() + { + return Err(SccmFindingValidationError::MissingRequiredField); + } + Ok(()) +} + +fn validate_roles(finding: &SccmFinding) -> Result<(), SccmFindingValidationError> { + if !finding.role.has_canonical_serialized_form() + || finding + .coverage_gaps + .iter() + .any(|gap| !gap.role.has_canonical_serialized_form()) + || finding + .next_artifacts + .iter() + .any(|request| !request.role.has_canonical_serialized_form()) + { + return Err(SccmFindingValidationError::InvalidRole); + } + Ok(()) +} + +/// Every reference a finding cites, across all three citation surfaces. +fn cited_evidence_references(finding: &SccmFinding) -> impl Iterator { + finding + .evidence + .iter() + .chain( + finding + .terminal_evidence + .iter() + .map(|terminal| &terminal.reference), + ) + .chain( + finding + .correlation_keys + .iter() + .filter_map(|key| key.evidence.as_ref()), + ) +} + +fn validate_all_evidence_references( + finding: &SccmFinding, +) -> Result<(), SccmFindingValidationError> { + let mut references_by_identity: BTreeMap<(&str, &str), &SccmEvidenceRef> = BTreeMap::new(); + for reference in cited_evidence_references(finding) { + validate_evidence_reference(reference)?; + if references_by_identity + .insert(evidence_identity(reference), reference) + .is_some_and(|existing| { + (existing.line_start, existing.line_end) + != (reference.line_start, reference.line_end) + }) + { + return Err(SccmFindingValidationError::ConflictingEvidenceReference); + } + } + validate_disjoint_evidence_spans(&references_by_identity) +} + +/// Whether two references claim overlapping physical lines of one source. +/// +/// Physical extent, not an identity tuple, is the question. Two logical +/// records of one artifact occupy disjoint lines, so any overlap means at +/// least one of them is not the record it claims to be. Bounds are inclusive, +/// so equal spans are the degenerate overlap and abutting spans (`1-2` beside +/// `3-4`) are not one. A reference that carries no bounds asserts no extent and +/// therefore cannot be shown to claim another reference's lines. +/// +/// This presumes bounds that already passed a validity gate. An inverted range +/// reads as empty under an inclusive test and would be silently judged disjoint +/// from everything, so the gate has to run first, not after: the spine calls +/// [`validate_evidence_reference`] on every reference before any pair reaches +/// this predicate, and a reducer that hands over unvalidated references is +/// responsible for its own gate. +pub(crate) fn evidence_references_overlap(left: &SccmEvidenceRef, right: &SccmEvidenceRef) -> bool { + if left.artifact_id != right.artifact_id { + return false; + } + matches!( + ( + left.line_start, + left.line_end, + right.line_start, + right.line_end, + ), + (Some(left_start), Some(left_end), Some(right_start), Some(right_end)) + if left_start <= right_end && right_start <= left_end + ) +} + +/// Rejects a finding whose citations claim the same physical records twice. +/// +/// Identity equality only catches an exact repeat of one range. It leaves the +/// wider hole open: `1-2` and `1-1` are different entry ids over the same +/// physical line, so both survive, each cites the same record, and +/// [`compare_evidence_refs`] then ranks one above the other on nothing but +/// span width. Both the management-point and client-policy reducers had to +/// close this in their own code; enforcing it here closes it for every finding +/// regardless of which reducer, or none, assembled it. +/// +/// References arrive keyed by identity, so each entry id contributes exactly +/// one span and a repeated identity is already a +/// [`SccmFindingValidationError::ConflictingEvidenceReference`]. Sorting each +/// artifact's spans by start line lets one pass answer the question: a span +/// that clears the widest span seen so far clears every earlier one, because +/// no earlier span starts later or ends further. +fn validate_disjoint_evidence_spans( + references_by_identity: &BTreeMap<(&str, &str), &SccmEvidenceRef>, +) -> Result<(), SccmFindingValidationError> { + let mut by_artifact: BTreeMap<&str, Vec<&SccmEvidenceRef>> = BTreeMap::new(); + for reference in references_by_identity.values() { + if reference.line_start.is_some() && reference.line_end.is_some() { + by_artifact + .entry(reference.artifact_id.as_str()) + .or_default() + .push(reference); + } + } + + for mut spans in by_artifact.into_values() { + spans.sort_by(|left, right| { + left.line_start + .cmp(&right.line_start) + .then_with(|| left.line_end.cmp(&right.line_end)) + }); + let mut widest: Option<&SccmEvidenceRef> = None; + for span in spans { + if widest.is_some_and(|widest| evidence_references_overlap(widest, span)) { + return Err(SccmFindingValidationError::OverlappingEvidenceReference); + } + if widest.is_none_or(|widest| widest.line_end < span.line_end) { + widest = Some(span); + } + } + } + Ok(()) +} + +fn validate_evidence_reference( + reference: &SccmEvidenceRef, +) -> Result<(), SccmFindingValidationError> { + let valid_line_range = match (reference.line_start, reference.line_end) { + (None, None) => true, + (Some(start), Some(end)) => start > 0 && end >= start, + _ => false, + }; + if !is_canonical_opaque_id(&reference.artifact_id) + || !is_canonical_opaque_id(&reference.entry_id) + || !valid_line_range + { + return Err(SccmFindingValidationError::InvalidEvidenceReference); + } + Ok(()) +} + +fn validate_coverage_gaps( + coverage_gaps: &[SccmFindingCoverageGap], +) -> Result<(), SccmFindingValidationError> { + let mut coverage_by_artifact: BTreeMap<&str, &SccmFindingCoverageGap> = BTreeMap::new(); + for gap in coverage_gaps { + if !is_canonical_opaque_id(&gap.artifact_id) || gap.coverage == SccmCoverageState::Captured + { + return Err(SccmFindingValidationError::InvalidCoverageGap); + } + + if let Some(previous) = coverage_by_artifact.get(gap.artifact_id.as_str()) { + if previous.role != gap.role || previous.coverage != gap.coverage { + return Err(SccmFindingValidationError::InvalidCoverageGap); + } + } else { + coverage_by_artifact.insert(gap.artifact_id.as_str(), gap); + } + } + Ok(()) +} + +fn validate_artifact_requests( + requests: &[SccmArtifactRequest], +) -> Result<(), SccmFindingValidationError> { + if requests.len() > MAX_SCCM_NEXT_ARTIFACT_REQUESTS { + return Err(SccmFindingValidationError::TooManyArtifactRequests); + } + + let catalog = declared_source_catalog(); + for request in requests { + if !is_canonical_opaque_id(&request.logical_id) { + return Err(SccmFindingValidationError::UndeclaredArtifactRequest); + } + + let mut logical_matches = catalog + .iter() + .filter(|entry| entry.logical_name == request.logical_id); + let Some(first_match) = logical_matches.next() else { + return Err(SccmFindingValidationError::UndeclaredArtifactRequest); + }; + let requested_source = if first_match.role == request.role { + first_match + } else if let Some(role_match) = logical_matches.find(|entry| entry.role == request.role) { + role_match + } else { + return Err(SccmFindingValidationError::ArtifactRequestRoleMismatch); + }; + if !is_bounded_request_reason( + &request.reason, + &requested_source.basename, + &requested_source.logical_name, + ) { + return Err(SccmFindingValidationError::InvalidArtifactRequestReason); + } + } + Ok(()) +} + +fn is_bounded_request_reason( + reason: &str, + requested_basename: &str, + requested_logical_id: &str, +) -> bool { + let trimmed = reason.trim(); + if !has_at_most_chars(reason, MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS) + || trimmed.is_empty() + || !trimmed + .chars() + .any(|character| character.is_ascii_alphanumeric()) + || contains_rooted_path(trimmed) + || trimmed.contains(['*', '?', '[', ']']) + { + return false; + } + + let lowercase = trimmed.to_ascii_lowercase(); + let authorization = CatalogArtifactRequestScope { + basename: requested_basename, + logical_id: requested_logical_id, + task_sequence_alias: requested_logical_id.eq_ignore_ascii_case("smsts"), + }; + if !reason_scope_is_within_catalog_artifact(&lowercase, &authorization) { + return false; + } + let clauses_are_bounded = request_clauses(&lowercase).all(|clause| { + !has_unbounded_request_scope(clause, requested_basename, requested_logical_id) + }); + clauses_are_bounded +} + +fn contains_rooted_path(reason: &str) -> bool { + let bytes = reason.as_bytes(); + bytes.iter().enumerate().any(|(index, byte)| { + matches!(byte, b'/' | b'\\') + && (index == 0 + || !matches!( + bytes[index - 1], + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b'.' + )) + }) +} + +// The reason remains descriptive text: authorization comes only from the +// catalog entry already selected by logical ID and role. Each clause is +// evaluated independently so punctuation cannot lend a later modifier either +// collection intent or an artifact identity from another clause. +#[derive(Clone, Copy)] +struct CatalogArtifactRequestScope<'a> { + basename: &'a str, + logical_id: &'a str, + task_sequence_alias: bool, +} + +#[derive(Clone, Copy)] +struct RequestReasonToken<'a> { + text: &'a str, + start: usize, + end: usize, +} + +fn reason_scope_is_within_catalog_artifact( + reason: &str, + authorization: &CatalogArtifactRequestScope<'_>, +) -> bool { + if has_unbounded_path_form(reason) { + return false; + } + + request_clauses(reason).all(|clause| { + let tokens = tokenize_request_reason(clause); + let identity_ranges = exact_collectable_identity_ranges(clause, authorization); + let is_confirmation = tokens.first().is_some_and(|token| token.text == "confirm"); + let contains_collection_directive = + tokens.iter().any(|token| is_collection_action(token.text)); + + if is_confirmation { + confirmation_clause_is_non_authorizing(clause, &tokens, &identity_ranges) + } else if contains_collection_directive { + collection_clause_is_catalog_bounded(clause, &tokens, &identity_ranges) + } else { + // A clause without a recognized collection action is never an + // alternate authorization path. It must independently match the + // same positive, non-authorizing evidence grammar. + narrative_clause_is_safe_observation(clause, &tokens, &identity_ranges) + } + }) +} + +fn has_unbounded_path_form(reason: &str) -> bool { + contains_rooted_path(reason) + || contains_drive_designator(reason) + || contains_environment_path(reason) + || reason + .as_bytes() + .windows(3) + .any(|window| matches!(window, b"../" | b"..\\")) +} + +fn contains_drive_designator(reason: &str) -> bool { + let bytes = reason.as_bytes(); + bytes.windows(2).enumerate().any(|(index, pair)| { + pair[0].is_ascii_alphabetic() + && pair[1] == b':' + && (index == 0 + || !matches!( + bytes[index - 1], + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' + )) + }) +} + +fn contains_environment_path(reason: &str) -> bool { + let percent_offsets = reason + .match_indices('%') + .map(|(index, _)| index) + .collect::>(); + let has_percent_expansion = percent_offsets.windows(2).any(|pair| { + let variable = &reason[pair[0] + 1..pair[1]]; + !variable.is_empty() + && variable + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') + }); + has_percent_expansion || reason.contains("$env:") +} + +fn tokenize_request_reason(reason: &str) -> Vec> { + let mut tokens = Vec::new(); + let mut token_start = None; + + for (index, character) in reason.char_indices() { + if character.is_ascii_alphanumeric() || character == '_' { + token_start.get_or_insert(index); + } else if let Some(start) = token_start.take() { + tokens.push(RequestReasonToken { + text: &reason[start..index], + start, + end: index, + }); + } + } + if let Some(start) = token_start { + tokens.push(RequestReasonToken { + text: &reason[start..], + start, + end: reason.len(), + }); + } + tokens +} + +fn exact_collectable_identity_ranges( + reason: &str, + authorization: &CatalogArtifactRequestScope<'_>, +) -> Vec<(usize, usize)> { + let basename = catalog_log_stem(authorization.basename).to_ascii_lowercase(); + let logical_id = authorization.logical_id.to_ascii_lowercase(); + let mut aliases = vec![ + format!("{basename}.log"), + format!("{logical_id}.log"), + format!("{basename} file"), + format!("{logical_id} file"), + format!("{basename} record"), + format!("{logical_id} record"), + format!("logs/{basename}.log"), + format!(r"logs\{basename}.log"), + ]; + if authorization.task_sequence_alias { + aliases.extend([ + "task sequence log".to_owned(), + "disk imaging task sequence log".to_owned(), + "disk-image task sequence log".to_owned(), + ]); + } + aliases.sort_by_key(|alias| std::cmp::Reverse(alias.len())); + aliases.dedup(); + + let mut ranges = aliases + .iter() + .flat_map(|alias| exact_alias_ranges(reason, alias)) + .collect::>(); + for terminal_alias in [basename, logical_id] { + ranges.extend(exact_terminal_alias_ranges(reason, &terminal_alias)); + } + ranges.sort_unstable(); + ranges.dedup(); + ranges +} + +fn exact_alias_ranges(reason: &str, alias: &str) -> Vec<(usize, usize)> { + reason + .match_indices(alias) + .filter_map(|(start, matched)| { + let end = start + matched.len(); + exact_identity_boundary(reason, start, end).then_some((start, end)) + }) + .collect() +} + +fn exact_terminal_alias_ranges(reason: &str, alias: &str) -> Vec<(usize, usize)> { + reason + .match_indices(alias) + .filter_map(|(start, matched)| { + let end = start + matched.len(); + (exact_identity_boundary(reason, start, end) && reason[end..].trim().is_empty()) + .then_some((start, end)) + }) + .collect() +} + +fn exact_identity_boundary(reason: &str, start: usize, end: usize) -> bool { + let before = reason[..start].chars().next_back(); + let after = reason[end..].chars().next(); + before.is_none_or(|character| !is_identity_continuation(character)) + && after.is_none_or(|character| !is_identity_continuation(character)) +} + +fn catalog_log_stem(basename: &str) -> &str { + basename.strip_suffix(".log").unwrap_or(basename) +} + +fn is_identity_continuation(character: char) -> bool { + character.is_ascii_alphanumeric() + || matches!( + character, + '_' | '-' + | '.' + | '\u{2010}' + | '\u{2011}' + | '\u{2012}' + | '\u{2013}' + | '\u{2014}' + | '\u{2015}' + | '\u{2212}' + ) +} + +fn collection_clause_is_catalog_bounded( + clause: &str, + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + if identity_ranges.is_empty() + || !collection_actions_have_exact_catalog_targets(clause, tokens, identity_ranges) + { + return false; + } + + tokens.iter().enumerate().all(|(index, token)| { + if token_is_covered_by_identity(token, identity_ranges) { + return true; + } + if is_external_collection_container(token.text) + || matches!(token.text, "data" | "everything") + { + return false; + } + if matches!(token.text, "file" | "files" | "log" | "logs") { + return token_is_adjacent_to_identity(tokens, index, identity_ranges); + } + true + }) +} + +fn collection_actions_have_exact_catalog_targets( + clause: &str, + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + let mut action_indices = tokens + .iter() + .enumerate() + .filter_map(|(index, token)| is_collection_action(token.text).then_some(index)) + .peekable(); + + while let Some(action_index) = action_indices.next() { + let segment_end = action_indices.peek().copied().unwrap_or(tokens.len()); + let has_following_action = action_indices.peek().is_some(); + if !collection_action_has_exact_catalog_target( + clause, + &tokens[action_index..segment_end], + identity_ranges, + has_following_action, + ) { + return false; + } + } + true +} + +fn collection_action_has_exact_catalog_target( + clause: &str, + segment: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], + has_following_action: bool, +) -> bool { + // Fail closed around the target shape: + // action + bounded modifiers + exact catalog identity + optional narrative. + // A catalog mention reached only after arbitrary target words is evidence + // context, not authorization for those earlier words. + let Some(action) = segment.first() else { + return false; + }; + let segment_end = segment.last().map_or(action.end, |token| token.end); + let Some((target_start, target_end)) = identity_ranges + .iter() + .filter(|(start, end)| *start >= action.end && *end <= segment_end) + .min_by_key(|(start, end)| (*start, std::cmp::Reverse(*end))) + .copied() + else { + return false; + }; + + let leading_is_target_grammar = segment + .iter() + .skip(1) + .take_while(|token| token.end <= target_start) + .all(|token| is_catalog_target_modifier(token.text)); + if !leading_is_target_grammar || has_unbound_dotted_target(clause, segment, identity_ranges) { + return false; + } + + let mut trailing = segment + .iter() + .filter(|token| token.start >= target_end) + .peekable(); + while trailing + .peek() + .is_some_and(|token| is_catalog_target_suffix(token.text)) + { + trailing.next(); + } + let trailing = trailing.copied().collect::>(); + if trailing.is_empty() { + return true; + } + if has_following_action + && trailing + .iter() + .all(|token| is_action_coordinator(token.text)) + { + return true; + } + if !is_collection_narrative_introducer(trailing[0].text) { + return false; + } + if !narrative_suffix_is_non_authorizing(clause, &trailing, identity_ranges) { + return false; + } + + trailing.iter().enumerate().all(|(index, token)| { + if !is_action_coordinator(token.text) { + return true; + } + trailing + .get(index + 1) + .is_some_and(|next| is_safe_narrative_continuation(next.text)) + }) +} + +fn is_catalog_target_modifier(token: &str) -> bool { + matches!( + token, + "a" | "all" + | "an" + | "cited" + | "complete" + | "current" + | "entire" + | "every" + | "exact" + | "full" + | "named" + | "of" + | "requested" + | "rotation" + | "rotations" + | "the" + | "whole" + ) +} + +fn is_catalog_target_suffix(token: &str) -> bool { + matches!(token, "entry" | "file" | "record") +} + +fn is_collection_narrative_introducer(token: &str) -> bool { + matches!( + token, + "after" + | "as" + | "because" + | "before" + | "cited" + | "for" + | "from" + | "recorded" + | "reported" + | "since" + | "with" + ) +} + +fn is_action_coordinator(token: &str) -> bool { + matches!(token, "and" | "plus" | "then") +} + +fn is_safe_narrative_continuation(token: &str) -> bool { + matches!( + token, + "after" | "as" | "because" | "before" | "for" | "from" | "since" | "with" + ) +} + +fn has_unbound_dotted_target( + clause: &str, + segment: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + segment.windows(2).any(|pair| { + clause[pair[0].end..pair[1].start].contains('.') + && !identity_ranges + .iter() + .any(|(start, end)| pair[0].start >= *start && pair[1].end <= *end) + }) +} + +fn confirmation_clause_is_non_authorizing( + clause: &str, + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + if has_unbound_dotted_target(clause, tokens, identity_ranges) + || tokens + .iter() + .any(|token| is_collection_scope_nominal(token.text)) + || has_passive_unbounded_confirmation_request(tokens, identity_ranges) + || !confirmation_tokens_match_defined_form(tokens, identity_ranges) + { + return false; + } + + let has_identity = !identity_ranges.is_empty(); + let has_retry = tokens.iter().any(|token| token.text == "retry"); + let has_root_cause = tokens + .windows(2) + .any(|pair| pair[0].text == "root" && pair[1].text == "cause"); + let has_assignment_or_policy = tokens + .iter() + .any(|token| matches!(token.text, "assignment" | "policy")); + let has_state_observation = tokens.iter().any(|token| { + matches!( + token.text, + "download" | "downloaded" | "encryption" | "image" | "imaging" | "state" | "status" + ) + }); + let has_download_observation = tokens + .iter() + .any(|token| matches!(token.text, "downloaded" | "state" | "status")); + + for token in tokens { + if is_collection_action(token.text) + && !(token.text == "download" && has_identity && has_download_observation) + { + return false; + } + if token.text.starts_with("recurs") && !(has_identity && has_retry) { + return false; + } + if token.text.starts_with("root") && !(has_identity && has_root_cause) { + return false; + } + if is_wide_scope_token(token.text) && !(has_identity && has_assignment_or_policy) { + return false; + } + if matches!( + token.text, + "directory" + | "directories" + | "drive" + | "drives" + | "filesystem" + | "filesystems" + | "folder" + | "folders" + | "volume" + | "volumes" + | "data" + | "everything" + ) { + return false; + } + if matches!(token.text, "disk" | "disks" | "file" | "files") + && !token_is_covered_by_identity(token, identity_ranges) + && !(has_identity && has_state_observation) + { + return false; + } + if matches!( + token.text, + "client" | "device" | "machine" | "site" | "system" + ) && !(has_identity && (has_assignment_or_policy || has_state_observation)) + { + return false; + } + } + true +} + +fn has_passive_unbounded_confirmation_request( + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + tokens + .iter() + .enumerate() + .any(|(auxiliary_index, auxiliary)| { + if !is_passive_auxiliary(auxiliary.text) { + return false; + } + + let body = &tokens[1..]; + let has_broad_target = body.iter().any(|token| is_broad_quantifier(token.text)) + && body.iter().any(|token| { + is_collection_target(token.text) + && !token_is_covered_by_identity(token, identity_ranges) + }); + if !has_broad_target { + return false; + } + + let predicate = &tokens[auxiliary_index + 1..]; + let broad_target_follows_auxiliary = predicate + .iter() + .any(|token| is_broad_quantifier(token.text)) + && predicate.iter().any(|token| { + is_collection_target(token.text) + && !token_is_covered_by_identity(token, identity_ranges) + }); + let has_explicit_bounded_state_observation = !broad_target_follows_auxiliary + && !identity_ranges.is_empty() + && predicate.iter().any(|token| token.text == "downloaded"); + + matches!(auxiliary.text, "must" | "need" | "needs" | "should") + || broad_target_follows_auxiliary + || predicate.iter().any(|token| { + matches!( + token.text, + "archived" + | "captured" + | "collected" + | "copied" + | "exported" + | "gathered" + | "included" + | "provided" + | "required" + ) + }) + || !has_explicit_bounded_state_observation + }) +} + +fn confirmation_tokens_match_defined_form( + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + let Some((confirmation, body)) = tokens.split_first() else { + return false; + }; + if confirmation.text != "confirm" { + return false; + } + + let has_subject = body.iter().any(|token| { + token_is_covered_by_identity(token, identity_ranges) + || is_evidence_narrative_subject(token.text) + || matches!( + token.text, + "behavior" + | "cause" + | "content" + | "download" + | "encryption" + | "image" + | "imaging" + | "retry" + ) + }); + has_subject + && body.iter().all(|token| { + token_is_covered_by_identity(token, identity_ranges) + || is_evidence_narrative_subject(token.text) + || is_evidence_narrative_predicate(token.text) + || matches!( + token.text, + "a" | "all" + | "as" + | "behavior" + | "bounded" + | "by" + | "cause" + | "cited" + | "client" + | "code" + | "complete" + | "content" + | "disk" + | "download" + | "downloaded" + | "encryption" + | "every" + | "file" + | "files" + | "for" + | "full" + | "ids" + | "image" + | "imaging" + | "in" + | "not" + | "of" + | "record" + | "recursive" + | "retry" + | "root" + | "system" + | "the" + | "whole" + | "wide" + ) + }) +} + +fn narrative_clause_has_no_collection_scope(tokens: &[RequestReasonToken<'_>]) -> bool { + let has_broad_scope = tokens.iter().any(|token| is_broad_quantifier(token.text)); + !tokens.iter().any(|token| { + token.text.starts_with("recurs") + || token.text.starts_with("root") + || is_wide_scope_token(token.text) + || is_external_collection_container(token.text) + || matches!(token.text, "data" | "everything") + || (has_broad_scope + && matches!( + token.text, + "file" | "files" | "log" | "logs" | "record" | "records" + )) + }) +} + +fn narrative_clause_is_safe_observation( + clause: &str, + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + narrative_tokens_are_non_authorizing(clause, tokens, identity_ranges) + && narrative_tokens_have_evidence_observation(tokens, identity_ranges) +} + +fn narrative_suffix_is_non_authorizing( + clause: &str, + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + narrative_tokens_are_non_authorizing(clause, tokens, identity_ranges) + && (narrative_tokens_have_evidence_observation(tokens, identity_ranges) + || is_bounded_reference_suffix(tokens) + || is_bounded_bundle_suffix(tokens) + || is_bounded_completion_suffix(tokens)) +} + +fn narrative_tokens_have_evidence_observation( + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + tokens.iter().all(|token| { + token_is_covered_by_identity(token, identity_ranges) + || is_evidence_narrative_subject(token.text) + || is_evidence_narrative_predicate(token.text) + || is_collection_narrative_introducer(token.text) + || is_action_coordinator(token.text) + || matches!( + token.text, + "a" | "an" | "by" | "in" | "not" | "of" | "review" | "the" | "to" + ) + }) && tokens.iter().any(|token| { + token_is_covered_by_identity(token, identity_ranges) + || is_evidence_narrative_subject(token.text) + }) && tokens + .iter() + .any(|token| is_evidence_narrative_predicate(token.text)) +} + +fn is_bounded_reference_suffix(tokens: &[RequestReasonToken<'_>]) -> bool { + tokens.first().is_some_and(|token| token.text == "cited") + && tokens.iter().any(|token| token.text == "by") + && tokens + .iter() + .any(|token| matches!(token.text, "assignment" | "entry")) + && tokens.iter().all(|token| { + matches!( + token.text, + "a" | "an" + | "assignment" + | "by" + | "cited" + | "entry" + | "exact" + | "id" + | "reference" + | "requested" + | "the" + ) + }) +} + +fn is_bounded_bundle_suffix(tokens: &[RequestReasonToken<'_>]) -> bool { + tokens.first().is_some_and(|token| token.text == "from") + && tokens.iter().any(|token| token.text == "bounded") + && tokens.iter().any(|token| token.text == "bundle") + && tokens.iter().all(|token| { + matches!( + token.text, + "a" | "an" | "bounded" | "bundle" | "from" | "requested" | "the" + ) + }) +} + +fn is_bounded_completion_suffix(tokens: &[RequestReasonToken<'_>]) -> bool { + tokens + .first() + .is_some_and(|token| matches!(token.text, "after" | "before")) + && tokens.iter().any(|token| token.text == "completion") + && tokens.iter().all(|token| { + token + .text + .chars() + .all(|character| character.is_ascii_digit()) + || matches!( + token.text, + "after" | "and" | "before" | "completion" | "plus" | "then" + ) + }) +} + +fn narrative_tokens_are_non_authorizing( + clause: &str, + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + narrative_clause_has_no_collection_scope(tokens) + && !has_unbound_dotted_target(clause, tokens, identity_ranges) + && !tokens + .iter() + .any(|token| is_collection_scope_nominal(token.text)) + && !has_unbound_passive_artifact_request(tokens, identity_ranges) +} + +fn is_collection_scope_nominal(token: &str) -> bool { + matches!( + token, + "collection" | "collections" | "inclusion" | "inclusions" | "traversal" | "traversals" + ) +} + +fn has_unbound_passive_artifact_request( + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + tokens.iter().enumerate().any(|(index, token)| { + is_passive_auxiliary(token.text) + && (!passive_subject_is_evidence(tokens, index, identity_ranges) + || !passive_predicate_is_evidence_state(tokens, index, identity_ranges)) + }) +} + +fn passive_subject_is_evidence( + tokens: &[RequestReasonToken<'_>], + auxiliary_index: usize, + identity_ranges: &[(usize, usize)], +) -> bool { + let subject_start = tokens[..auxiliary_index] + .iter() + .rposition(|token| { + is_collection_narrative_introducer(token.text) || is_action_coordinator(token.text) + }) + .map_or(0, |index| index + 1); + + tokens[subject_start..auxiliary_index].iter().any(|token| { + token_is_covered_by_identity(token, identity_ranges) + || is_evidence_narrative_subject(token.text) + }) +} + +fn passive_predicate_is_evidence_state( + tokens: &[RequestReasonToken<'_>], + auxiliary_index: usize, + identity_ranges: &[(usize, usize)], +) -> bool { + let predicate = &tokens[auxiliary_index + 1..]; + if predicate.is_empty() { + return false; + } + + let has_exact_identity = predicate + .iter() + .any(|token| token_is_covered_by_identity(token, identity_ranges)); + predicate.iter().all(|token| { + token_is_covered_by_identity(token, identity_ranges) + || is_evidence_narrative_subject(token.text) + || is_evidence_narrative_predicate(token.text) + || is_collection_narrative_introducer(token.text) + || is_action_coordinator(token.text) + || matches!( + token.text, + "a" | "an" | "by" | "in" | "not" | "of" | "the" | "to" + ) + || (has_exact_identity + && matches!( + token.text, + "contain" + | "contained" + | "containing" + | "contains" + | "exact" + | "include" + | "included" + | "includes" + | "including" + | "requested" + )) + }) +} + +fn is_passive_auxiliary(token: &str) -> bool { + matches!( + token, + "are" + | "be" + | "been" + | "being" + | "had" + | "has" + | "have" + | "is" + | "must" + | "need" + | "needs" + | "should" + | "was" + | "were" + ) +} + +fn is_evidence_narrative_subject(token: &str) -> bool { + matches!( + token, + "access" + | "assignment" + | "coverage" + | "error" + | "evaluation" + | "evidence" + | "failure" + | "finding" + | "outcome" + | "policy" + | "request" + | "response" + | "source" + | "state" + | "status" + ) +} + +fn is_evidence_narrative_predicate(token: &str) -> bool { + is_passive_auxiliary(token) + || matches!( + token, + "available" + | "capped" + | "captured" + | "denied" + | "failed" + | "had" + | "has" + | "have" + | "malformed" + | "missing" + | "partial" + | "provided" + | "recorded" + | "reported" + | "required" + | "skipped" + | "succeeded" + | "unavailable" + | "unsupported" + ) +} + +fn is_wide_scope_token(token: &str) -> bool { + token == "wide" + || token + .strip_suffix("wide") + .is_some_and(|prefix| !prefix.is_empty()) +} + +fn is_external_collection_container(token: &str) -> bool { + matches!( + token, + "client" + | "clients" + | "device" + | "devices" + | "directory" + | "directories" + | "disk" + | "disks" + | "drive" + | "drives" + | "filesystem" + | "filesystems" + | "folder" + | "folders" + | "machine" + | "machines" + | "site" + | "sites" + | "system" + | "systems" + | "volume" + | "volumes" + ) +} + +fn token_is_covered_by_identity( + token: &RequestReasonToken<'_>, + identity_ranges: &[(usize, usize)], +) -> bool { + identity_ranges + .iter() + .any(|(start, end)| token.start >= *start && token.end <= *end) +} + +fn token_is_adjacent_to_identity( + tokens: &[RequestReasonToken<'_>], + token_index: usize, + identity_ranges: &[(usize, usize)], +) -> bool { + token_index + .checked_sub(1) + .and_then(|index| tokens.get(index)) + .is_some_and(|token| token_is_covered_by_identity(token, identity_ranges)) + || tokens + .get(token_index + 1) + .is_some_and(|token| token_is_covered_by_identity(token, identity_ranges)) +} + +fn request_clauses(reason: &str) -> impl Iterator { + let mut clauses = Vec::new(); + let mut start = 0; + let mut characters = reason.char_indices().peekable(); + + while let Some((index, character)) = characters.next() { + let next = characters.peek().map(|(_, next)| *next); + let is_sentence_period = + character == '.' && next.is_none_or(|next| !next.is_ascii_alphanumeric()); + if matches!(character, ';' | '\n' | '\r' | '!' | '?') || is_sentence_period { + let clause = reason[start..index].trim(); + if !clause.is_empty() { + clauses.push(clause); + } + start = index + character.len_utf8(); + } + } + + let trailing = reason[start..].trim(); + if !trailing.is_empty() { + clauses.push(trailing); + } + clauses.into_iter() +} + +fn has_unbounded_request_scope( + clause: &str, + requested_basename: &str, + requested_logical_id: &str, +) -> bool { + let tokens = clause + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .filter(|token| !token.is_empty()) + .collect::>(); + + tokens.iter().any(|token| is_compact_unbounded_scope(token)) + || tokens + .iter() + .any(|token| matches!(*token, "glob" | "globs" | "globbing")) + || has_recursive_collection_scope(&tokens) + || has_wide_collection_scope(&tokens) + || has_root_collection_scope(&tokens) + || has_unscoped_broad_collection_scope(&tokens, requested_basename, requested_logical_id) +} + +fn is_collection_action(token: &str) -> bool { + matches!( + token, + "archive" + | "capture" + | "collect" + | "copy" + | "download" + | "enumerate" + | "export" + | "gather" + | "inspect" + | "obtain" + | "read" + | "scan" + | "search" + | "walk" + | "traverse" + ) +} + +fn is_collection_target(token: &str) -> bool { + matches!( + token, + "file" + | "files" + | "directory" + | "directories" + | "folder" + | "folders" + | "drive" + | "drives" + | "disk" + | "disks" + | "volume" + | "volumes" + | "filesystem" + | "filesystems" + | "log" + | "logs" + | "artifact" + | "artifacts" + | "evidence" + ) +} + +fn is_compact_unbounded_scope(token: &str) -> bool { + ["all", "every", "entire", "whole", "full", "complete"] + .iter() + .any(|prefix| token.strip_prefix(prefix).is_some_and(is_collection_target)) +} + +fn is_collection_container(token: &str) -> bool { + matches!( + token, + "client" + | "clients" + | "device" + | "devices" + | "directory" + | "directories" + | "disk" + | "disks" + | "drive" + | "drives" + | "filesystem" + | "filesystems" + | "folder" + | "folders" + | "machine" + | "machines" + | "site" + | "sites" + | "system" + | "systems" + | "volume" + | "volumes" + ) +} + +fn is_collection_scope_subject(token: &str) -> bool { + is_collection_target(token) || matches!(token, "archive" | "collection" | "scope" | "traversal") +} + +fn has_recursive_collection_scope(tokens: &[&str]) -> bool { + let mentions_recursion = tokens.iter().any(|token| token.starts_with("recurs")); + let describes_collection = tokens.iter().any(|token| { + is_collection_action(token) + || is_collection_container(token) + || matches!(*token, "collection" | "inclusion" | "traversal") + }); + mentions_recursion && describes_collection +} + +fn has_wide_collection_scope(tokens: &[&str]) -> bool { + tokens.iter().enumerate().any(|(index, token)| { + let compound = token + .strip_suffix("wide") + .is_some_and(is_collection_container) + && tokens + .get(index + 1) + .is_some_and(|target| is_collection_scope_subject(target)); + let separated = is_collection_container(token) + && tokens.get(index + 1) == Some(&"wide") + && tokens + .get(index + 2) + .is_some_and(|target| is_collection_scope_subject(target)); + + compound || separated + }) +} + +fn has_root_collection_scope(tokens: &[&str]) -> bool { + tokens.iter().enumerate().any(|(root_index, token)| { + if !token.starts_with("root") || tokens.get(root_index + 1) == Some(&"cause") { + return false; + } + tokens.iter().any(|candidate| { + is_collection_action(candidate) || is_collection_scope_subject(candidate) + }) + }) +} + +fn is_broad_quantifier(token: &str) -> bool { + matches!( + token, + "all" | "every" | "entire" | "whole" | "full" | "complete" + ) +} + +fn has_unscoped_broad_collection_scope( + tokens: &[&str], + requested_basename: &str, + requested_logical_id: &str, +) -> bool { + tokens.iter().enumerate().any(|(quantifier_index, token)| { + if !is_broad_quantifier(token) { + return false; + } + + let (scope_start, scope_end) = broad_scope_segment(tokens, quantifier_index); + let scope = &tokens[scope_start..scope_end]; + let targets = scope + .iter() + .enumerate() + .filter(|(_, candidate)| is_collection_target(candidate)) + .map(|(index, _)| index) + .collect::>(); + if targets.is_empty() { + return false; + } + + let identity_ranges = + requested_artifact_identity_ranges(scope, requested_basename, requested_logical_id); + if identity_ranges.is_empty() || has_environment_collection_scope(scope) { + return true; + } + + targets.into_iter().any(|target_index| { + !target_is_bounded_to_requested_artifact( + scope, + target_index, + &identity_ranges, + requested_logical_id, + ) + }) + }) +} + +fn broad_scope_segment(tokens: &[&str], quantifier_index: usize) -> (usize, usize) { + let is_boundary = |index: usize| { + matches!(tokens[index], "plus" | "also" | "then") + || (tokens[index] == "and" + && tokens + .get(index + 1) + .is_some_and(|next| is_broad_quantifier(next) || is_collection_action(next))) + }; + let start = (0..quantifier_index) + .rev() + .find(|index| is_boundary(*index)) + .map_or(0, |index| index + 1); + let end = ((quantifier_index + 1)..tokens.len()) + .find(|index| is_boundary(*index)) + .unwrap_or(tokens.len()); + (start, end) +} + +fn requested_artifact_identity_ranges( + tokens: &[&str], + requested_basename: &str, + requested_logical_id: &str, +) -> Vec<(usize, usize)> { + let basename_stem = catalog_log_stem(requested_basename); + let basename = normalize_catalog_identity(basename_stem); + let logical_id = normalize_catalog_identity(requested_logical_id); + let mut ranges = tokens + .iter() + .enumerate() + .filter(|(_, token)| **token == basename || **token == logical_id) + .map(|(index, _)| (index, index + 1)) + .collect::>(); + + // Exact request authorization is established earlier from the selected + // catalog entry and its punctuated alias. This additional range only lets + // the broad-scope guard recognize that same basename after tokenization + // splits a catalog identity such as `client.msi` at its punctuation. + let basename_components = basename_stem + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .filter(|component| !component.is_empty()) + .map(normalize_catalog_identity) + .collect::>(); + if basename_components.len() > 1 { + ranges.extend( + tokens + .windows(basename_components.len()) + .enumerate() + .filter_map(|(index, window)| { + window + .iter() + .zip(&basename_components) + .all(|(token, component)| *token == component) + .then_some((index, index + window.len())) + }), + ); + } + + if logical_id == "smsts" { + ranges.extend(tokens.windows(3).enumerate().filter_map(|(index, window)| { + (window == ["task", "sequence", "log"]).then_some((index, index + 3)) + })); + } + ranges.sort_unstable(); + ranges.dedup(); + ranges +} + +fn normalize_catalog_identity(identity: &str) -> String { + identity + .chars() + .filter(|character| character.is_ascii_alphanumeric() || *character == '_') + .flat_map(char::to_lowercase) + .collect() +} + +fn has_environment_collection_scope(tokens: &[&str]) -> bool { + tokens + .windows(2) + .any(|pair| is_collection_container(pair[0]) && is_collection_target(pair[1])) + || tokens.iter().enumerate().any(|(index, token)| { + matches!(*token, "across" | "from" | "on" | "throughout" | "under") + && tokens + .iter() + .skip(index + 1) + .take(3) + .any(|candidate| is_collection_container(candidate)) + }) +} + +fn target_is_bounded_to_requested_artifact( + tokens: &[&str], + target_index: usize, + identity_ranges: &[(usize, usize)], + requested_logical_id: &str, +) -> bool { + let identity_precedes_target = identity_ranges + .iter() + .any(|(start, end)| *start <= target_index && target_index.saturating_sub(*end) <= 2); + let is_state_observation = tokens.iter().any(|token| { + matches!( + *token, + "download" | "downloaded" | "encryption" | "image" | "imaging" | "state" | "status" + ) + }); + + match tokens[target_index] { + "directory" | "directories" | "disk" | "disks" | "drive" | "drives" | "filesystem" + | "filesystems" | "folder" | "folders" | "volume" | "volumes" => { + is_state_observation + && (requested_logical_id.eq_ignore_ascii_case("smsts") + || tokens + .iter() + .any(|token| matches!(*token, "encryption" | "status"))) + } + "file" | "files" => identity_precedes_target || is_state_observation, + "log" | "logs" => identity_precedes_target, + _ => true, + } +} + +fn validate_terminal_evidence( + evidence: &[SccmEvidenceRef], + terminal_evidence: &[SccmTerminalEvidence], +) -> Result<(), SccmFindingValidationError> { + for terminal in terminal_evidence { + if !evidence.contains(&terminal.reference) { + return Err(SccmFindingValidationError::TerminalEvidenceNotCited); + } + if terminal.kind != SccmTerminalEvidenceKind::ObservedFailure { + return Err(SccmFindingValidationError::InvalidTerminalEvidence); + } + } + Ok(()) +} + +fn validate_correlation_key_evidence( + evidence: &[SccmEvidenceRef], + correlation_keys: &[SccmCorrelationKey], +) -> Result<(), SccmFindingValidationError> { + for key in correlation_keys { + let normalized = normalize_key(key.kind.clone(), &key.raw); + let has_canonical_value = !key.raw.is_empty() + && key.raw.trim() == key.raw + && has_at_most_chars(&key.raw, MAX_SCCM_CORRELATION_KEY_VALUE_CHARS) + && !key.normalized.is_empty() + && key.normalized.trim() == key.normalized + && has_at_most_chars(&key.normalized, MAX_SCCM_CORRELATION_KEY_VALUE_CHARS) + && normalized.confidence == SccmKeyConfidence::Exact + && normalized.normalized == key.normalized; + let has_valid_span = match (key.start, key.end) { + (None, None) => true, + (Some(start), Some(end)) => { + start < end && end - start == key.raw.encode_utf16().count() + } + _ => false, + }; + let has_canonical_profile = key + .extraction_profile_id + .as_deref() + .is_none_or(is_canonical_opaque_id); + let has_valid_confidence = matches!(key.confidence, SccmKeyConfidence::Low) + || key + .extraction_profile_id + .as_deref() + .is_some_and(is_registered_stable_profile); + if !has_canonical_value + || !has_valid_span + || !has_canonical_profile + || !has_valid_confidence + { + return Err(SccmFindingValidationError::InvalidCorrelationKey); + } + + let Some(reference) = &key.evidence else { + return Err(SccmFindingValidationError::CorrelationKeyMissingEvidence); + }; + // The containment check below only proves the reference is cited, and + // a standalone key is its own citation set, which makes containment + // self-satisfying. Validate the reference here rather than at the call + // sites so no caller can reach the trivially satisfied check with an + // unvalidated reference. + validate_evidence_reference(reference)?; + if !evidence.contains(reference) { + return Err(SccmFindingValidationError::CorrelationKeyEvidenceNotCited); + } + } + Ok(()) +} + +fn has_profiled_key_corroboration(keys: &[SccmCorrelationKey]) -> bool { + for candidate in keys { + if !is_corroborating_key(candidate) { + continue; + } + let (Some(candidate_profile), Some(candidate_reference)) = ( + candidate.extraction_profile_id.as_deref(), + candidate.evidence.as_ref(), + ) else { + continue; + }; + let candidate_identity = evidence_identity(candidate_reference); + let mut distinct_identities = vec![candidate_identity]; + + for peer in keys { + if !is_corroborating_key(peer) + || peer.kind != candidate.kind + || peer.normalized != candidate.normalized + || peer.extraction_profile_id.as_deref() != Some(candidate_profile) + { + continue; + } + let Some(peer_reference) = peer.evidence.as_ref() else { + continue; + }; + let peer_identity = evidence_identity(peer_reference); + if !distinct_identities.contains(&peer_identity) { + distinct_identities.push(peer_identity); + } + } + + if distinct_identities.len() >= 2 { + return true; + } + } + false +} + +fn is_corroborating_key(key: &SccmCorrelationKey) -> bool { + matches!( + key.confidence, + SccmKeyConfidence::Strong | SccmKeyConfidence::Exact + ) && key + .extraction_profile_id + .as_deref() + .is_some_and(is_registered_stable_profile) + && !key.normalized.trim().is_empty() + && key.evidence.is_some() +} + +fn is_registered_stable_profile(profile_id: &str) -> bool { + REGISTERED_STABLE_CORRELATION_PROFILE_IDS.contains(&profile_id) +} + +fn evidence_identity(reference: &SccmEvidenceRef) -> (&str, &str) { + (&reference.artifact_id, &reference.entry_id) +} + +fn is_canonical_opaque_id(value: &str) -> bool { + !value.is_empty() && value.trim() == value && has_at_most_chars(value, MAX_SCCM_OPAQUE_ID_CHARS) +} + +pub(crate) fn has_at_most_chars(value: &str, maximum: usize) -> bool { + value.chars().nth(maximum).is_none() +} + +fn normalize_finding(finding: &mut SccmFinding) { + finding.title = finding.title.trim().to_owned(); + finding.summary = finding.summary.trim().to_owned(); + for request in &mut finding.next_artifacts { + request.reason = request.reason.trim().to_owned(); + } + + finding.evidence.sort_by(compare_evidence_refs); + finding.evidence.dedup(); + + finding.terminal_evidence.sort_by(compare_terminal_evidence); + finding.terminal_evidence.dedup(); + + finding.coverage_gaps.sort_by(compare_coverage_gaps); + finding.coverage_gaps.dedup(); + + finding.correlation_keys.sort_by(compare_correlation_keys); + finding.correlation_keys.dedup(); + + finding.next_artifacts.sort_by(compare_artifact_requests); + finding.next_artifacts.dedup(); +} + +fn compare_evidence_refs(left: &SccmEvidenceRef, right: &SccmEvidenceRef) -> Ordering { + left.artifact_id + .cmp(&right.artifact_id) + .then_with(|| left.entry_id.cmp(&right.entry_id)) + .then_with(|| left.line_start.cmp(&right.line_start)) + .then_with(|| left.line_end.cmp(&right.line_end)) +} + +fn compare_terminal_evidence( + left: &SccmTerminalEvidence, + right: &SccmTerminalEvidence, +) -> Ordering { + compare_evidence_refs(&left.reference, &right.reference).then_with(|| { + left.kind + .serialized_name() + .cmp(right.kind.serialized_name()) + }) +} + +fn compare_coverage_gaps( + left: &SccmFindingCoverageGap, + right: &SccmFindingCoverageGap, +) -> Ordering { + left.artifact_id + .cmp(&right.artifact_id) + .then_with(|| compare_roles(&left.role, &right.role)) + .then_with(|| { + coverage_state_order(&left.coverage).cmp(&coverage_state_order(&right.coverage)) + }) +} + +fn compare_correlation_keys(left: &SccmCorrelationKey, right: &SccmCorrelationKey) -> Ordering { + correlation_key_kind_order(&left.kind) + .cmp(&correlation_key_kind_order(&right.kind)) + .then_with(|| left.normalized.cmp(&right.normalized)) + .then_with(|| { + key_confidence_order(&left.confidence).cmp(&key_confidence_order(&right.confidence)) + }) + .then_with(|| left.extraction_profile_id.cmp(&right.extraction_profile_id)) + .then_with(|| compare_optional_evidence_refs(&left.evidence, &right.evidence)) + .then_with(|| left.raw.cmp(&right.raw)) + .then_with(|| left.start.cmp(&right.start)) + .then_with(|| left.end.cmp(&right.end)) +} + +fn compare_optional_evidence_refs( + left: &Option, + right: &Option, +) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => compare_evidence_refs(left, right), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + } +} + +fn compare_artifact_requests(left: &SccmArtifactRequest, right: &SccmArtifactRequest) -> Ordering { + left.logical_id + .cmp(&right.logical_id) + .then_with(|| compare_roles(&left.role, &right.role)) + .then_with(|| left.reason.cmp(&right.reason)) +} + +fn compare_roles(left: &SccmRole, right: &SccmRole) -> Ordering { + role_order(left) + .cmp(&role_order(right)) + .then_with(|| unknown_role_value(left).cmp(unknown_role_value(right))) +} + +fn role_order(role: &SccmRole) -> u8 { + match role { + SccmRole::Client => 0, + SccmRole::SiteServer => 1, + SccmRole::ManagementPoint => 2, + SccmRole::DistributionPoint => 3, + SccmRole::SoftwareUpdatePoint => 4, + SccmRole::WsUs => 5, + SccmRole::Provider => 6, + SccmRole::AdminService => 7, + SccmRole::Unknown(_) => 8, + } +} + +fn unknown_role_value(role: &SccmRole) -> &str { + match role { + SccmRole::Unknown(value) => value, + _ => "", + } +} + +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, + } +} + +fn correlation_key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { + match kind { + SccmCorrelationKeyKind::AssignmentId => 0, + SccmCorrelationKeyKind::PolicyId => 1, + SccmCorrelationKeyKind::ClientGuid => 2, + SccmCorrelationKeyKind::PackageId => 3, + SccmCorrelationKeyKind::ContentId => 4, + SccmCorrelationKeyKind::SiteCode => 5, + SccmCorrelationKeyKind::ServerHost => 6, + SccmCorrelationKeyKind::CiId => 7, + SccmCorrelationKeyKind::UpdateId => 8, + SccmCorrelationKeyKind::KbId => 9, + SccmCorrelationKeyKind::BitsJobId => 10, + SccmCorrelationKeyKind::TaskSequenceExecutionId => 11, + SccmCorrelationKeyKind::RequestId => 12, + SccmCorrelationKeyKind::TopicId => 13, + SccmCorrelationKeyKind::StateMessageId => 14, + SccmCorrelationKeyKind::HierarchyMessageId => 15, + SccmCorrelationKeyKind::HierarchyLinkId => 16, + SccmCorrelationKeyKind::InventoryCycleId => 17, + SccmCorrelationKeyKind::ReportId => 18, + SccmCorrelationKeyKind::ResourceHandle => 19, + SccmCorrelationKeyKind::ComplianceCiId => 20, + SccmCorrelationKeyKind::BaselineId => 21, + SccmCorrelationKeyKind::ComplianceStateId => 22, + SccmCorrelationKeyKind::MeteringCycleId => 23, + SccmCorrelationKeyKind::RuleId => 24, + } +} + +fn key_confidence_order(confidence: &SccmKeyConfidence) -> u8 { + match confidence { + SccmKeyConfidence::Low => 0, + SccmKeyConfidence::Strong => 1, + SccmKeyConfidence::Exact => 2, + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/ingest.rs b/crates/cmtraceopen-parser/src/sccm/ingest.rs new file mode 100644 index 000000000..9c8654793 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/ingest.rs @@ -0,0 +1,11 @@ +use crate::parser::ccm::scan_logical_records; + +use super::evidence::SccmRawEvidenceSnapshot; +use super::models::{SccmArtifact, SccmEvidence}; + +pub fn normalize_ccm_artifact(artifact: SccmArtifact, content: &str) -> Vec { + scan_logical_records(content, &artifact.display_name) + .into_iter() + .map(|record| SccmRawEvidenceSnapshot::from_record(&artifact, record).export()) + .collect() +} diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs new file mode 100644 index 000000000..ad9c438da --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -0,0 +1,674 @@ +use std::sync::OnceLock; + +use regex::Regex; + +use super::catalog::SccmArtifactFamily; +use super::findings::{has_at_most_chars, MAX_SCCM_CORRELATION_KEY_VALUE_CHARS}; +use super::models::{ + SccmCorrelationKey, SccmCorrelationKeyKind, SccmEvidence, SccmExtractionGap, + SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmKeyConfidence, + SccmKeyExtractionResult, +}; + +pub const SCCM_EXPERIMENTAL_KEY_PROFILE_ID: &str = "sccm-keys-5.00.9128-experimental-v1"; +/// Exact-key authority for the committed synthetic policy fixtures only. +/// `Stable` describes this closed test corpus shape; it does not validate a +/// production ConfigMgr release. +pub const SCCM_POLICY_KEY_PROFILE_ID: &str = "policy-client-5.00.test-v1"; +pub const SCCM_HIERARCHY_KEY_PROFILE_ID: &str = "sccm-hierarchy-5.00.test-stable-v1"; +pub const SCCM_PROVIDER_SYNTHETIC_KEY_PROFILE_ID: &str = "provider-server-5.00.test-v1"; +pub const SCCM_ADMIN_SERVICE_SYNTHETIC_KEY_PROFILE_ID: &str = "admin-service-server-5.00.test-v1"; +const EXPERIMENTAL_VERSION_PREFIX: &str = "5.00.9128."; +const POLICY_TEST_VERSION: &str = "5.00.TEST.0000"; +const HIERARCHY_SYNTHETIC_VERSION: &str = "5.00.TEST"; +const SYNTHETIC_VERSION: &str = "5.00.TEST"; + +impl SccmExtractionProfile { + pub fn for_version(configmgr_version: Option<&str>) -> Self { + let selected_configmgr_version = configmgr_version + .map(str::trim) + .filter(|version| !version.is_empty()) + .map(str::to_owned); + + match selected_configmgr_version { + Some(version) + if is_canonical_configmgr_version(&version) + && version.starts_with(EXPERIMENTAL_VERSION_PREFIX) => + { + Self { + profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + configmgr_version_prefixes: vec![EXPERIMENTAL_VERSION_PREFIX.to_owned()], + validated_artifact_families: Vec::new(), + selected_configmgr_version: Some(version), + maturity: SccmExtractionProfileMaturity::Experimental, + } + } + Some(version) => Self { + profile_id: "sccm-keys-unvalidated-version-v1".to_owned(), + configmgr_version_prefixes: Vec::new(), + validated_artifact_families: Vec::new(), + selected_configmgr_version: Some(version), + maturity: SccmExtractionProfileMaturity::Unvalidated, + }, + None => Self { + profile_id: "sccm-keys-version-missing-v1".to_owned(), + configmgr_version_prefixes: Vec::new(), + validated_artifact_families: Vec::new(), + selected_configmgr_version: None, + maturity: SccmExtractionProfileMaturity::Unvalidated, + }, + } + } + + /// Selects the centrally defined built-in version profile and binds it to + /// the catalog family that produced one admitted raw-CCM artifact. + /// + /// Family binding does not validate a new extractor family. `extract_keys` + /// independently recognizes only the executable-fixture registry below; + /// other families remain visible through `UnvalidatedProfile` gaps. + pub(crate) fn for_artifact_family( + configmgr_version: Option<&str>, + family: &SccmArtifactFamily, + ) -> Self { + if family == &SccmArtifactFamily::Hierarchy + && configmgr_version.map(str::trim) == Some(HIERARCHY_SYNTHETIC_VERSION) + { + return Self { + profile_id: SCCM_HIERARCHY_KEY_PROFILE_ID.to_owned(), + configmgr_version_prefixes: vec![HIERARCHY_SYNTHETIC_VERSION.to_owned()], + validated_artifact_families: vec![SccmArtifactFamily::Hierarchy], + selected_configmgr_version: Some(HIERARCHY_SYNTHETIC_VERSION.to_owned()), + maturity: SccmExtractionProfileMaturity::Stable, + }; + } + if configmgr_version == Some(SYNTHETIC_VERSION) { + let profile_id = match family { + SccmArtifactFamily::Provider => SCCM_PROVIDER_SYNTHETIC_KEY_PROFILE_ID, + SccmArtifactFamily::AdminService => SCCM_ADMIN_SERVICE_SYNTHETIC_KEY_PROFILE_ID, + _ => "", + }; + if !profile_id.is_empty() { + return Self { + profile_id: profile_id.to_owned(), + configmgr_version_prefixes: vec![SYNTHETIC_VERSION.to_owned()], + validated_artifact_families: vec![family.clone()], + selected_configmgr_version: Some(SYNTHETIC_VERSION.to_owned()), + maturity: SccmExtractionProfileMaturity::Experimental, + }; + } + } + if configmgr_version == Some(POLICY_TEST_VERSION) + && matches!(family, SccmArtifactFamily::ClientPolicy) + { + return Self { + profile_id: SCCM_POLICY_KEY_PROFILE_ID.to_owned(), + configmgr_version_prefixes: vec![POLICY_TEST_VERSION.to_owned()], + validated_artifact_families: vec![SccmArtifactFamily::ClientPolicy], + selected_configmgr_version: Some(POLICY_TEST_VERSION.to_owned()), + maturity: SccmExtractionProfileMaturity::Stable, + }; + } + let mut profile = Self::for_version(configmgr_version); + profile.validated_artifact_families = vec![family.clone()]; + profile + } +} + +struct KeyPattern { + kind: SccmCorrelationKeyKind, + regex: Regex, +} + +#[derive(Debug)] +struct KeyCandidate<'a> { + kind: SccmCorrelationKeyKind, + raw: &'a str, + start: usize, + end: usize, +} + +fn key_patterns() -> &'static [KeyPattern] { + static CELL: OnceLock> = OnceLock::new(); + CELL.get_or_init(|| { + [ + ( + SccmCorrelationKeyKind::AssignmentId, + r"(?i:\bassignment[ \t]*id)[ \t]*=[ \t]*(?P\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::PolicyId, + r"(?i:\bpolicy[ \t]*id)[ \t]*=[ \t]*(?P\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::ClientGuid, + r"(?i:\bclient[ \t]*guid)[ \t]*=[ \t]*(?P(?i:guid:)?\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::PackageId, + r"(?i:\bpackage[ \t]*id)[ \t]*=[ \t]*(?P[A-Za-z0-9]{8}[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::ContentId, + r"(?i:\bcontent[ \t]*id)[ \t]*=[ \t]*(?P[A-Za-z0-9][A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::SiteCode, + r"(?i:\bsite[ \t]*code)[ \t]*=[ \t]*(?P[A-Za-z0-9]{3}[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::ServerHost, + r"(?i:\bserver[ \t]*host)[ \t]*=[ \t]*(?P[A-Za-z0-9][A-Za-z0-9._-]*)", + ), + ( + SccmCorrelationKeyKind::CiId, + r"(?i:\b(?:ci|configuration[ \t]+item)[ \t]*id)[ \t]*=[ \t]*(?P[0-9]+[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::UpdateId, + r"(?i:\bupdate[ \t]*id)[ \t]*=[ \t]*(?P\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::KbId, + r"(?i:\b(?:kb|knowledge[ \t]+base)(?:[ \t]*id)?)[ \t]*=[ \t]*(?P(?i:kb)?[0-9]+[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::BitsJobId, + r"(?i:\bbits[ \t]*job[ \t]*id)[ \t]*=[ \t]*(?P\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::TaskSequenceExecutionId, + r"(?i:\btask[ \t]*sequence[ \t]*execution[ \t]*id)[ \t]*=[ \t]*(?P\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::RequestId, + r"(?i:\brequest[ \t]*id)[ \t]*=[ \t]*(?P\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::TopicId, + r"(?i:\btopic[ \t]*id)[ \t]*=[ \t]*(?P\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?[A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::StateMessageId, + r"(?i:\bstate[ \t]*message[ \t]*id)[ \t]*=[ \t]*(?P[0-9]+[A-Za-z0-9_.-]*)", + ), + ] + .into_iter() + .map(|(kind, pattern)| KeyPattern { + kind, + regex: Regex::new(pattern).expect("SCCM key regex must compile"), + }) + .collect() + }) +} + +fn hierarchy_key_patterns() -> &'static [KeyPattern] { + static CELL: OnceLock> = OnceLock::new(); + CELL.get_or_init(|| { + [ + ( + SccmCorrelationKeyKind::HierarchyMessageId, + r"(?i:\bmessage[ \t]*id)[ \t]*=[ \t]*(?Pmsg-[A-Za-z0-9][A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::HierarchyLinkId, + r"(?i:\blink[ \t]*id)[ \t]*=[ \t]*(?Plink-[A-Za-z0-9][A-Za-z0-9_.-]*)", + ), + ( + SccmCorrelationKeyKind::SiteCode, + r"(?i:\b(?:origin|target)[ \t]*site)[ \t]*=[ \t]*(?P[A-Z]{3})", + ), + ] + .into_iter() + .map(|(kind, pattern)| KeyPattern { + kind, + regex: Regex::new(pattern).expect("SCCM hierarchy key regex must compile"), + }) + .collect() + }) +} + +pub fn normalize_key(kind: SccmCorrelationKeyKind, raw: &str) -> SccmCorrelationKey { + let (normalized, confidence) = normalize_value(&kind, raw); + SccmCorrelationKey { + kind, + raw: raw.to_owned(), + normalized, + confidence, + extraction_profile_id: None, + evidence: None, + start: None, + end: None, + } +} + +pub fn extract_keys( + evidence: &SccmEvidence, + profile: &SccmExtractionProfile, +) -> SccmKeyExtractionResult { + extract_keys_with_authority(evidence, profile, false) +} + +pub(crate) fn extract_admitted_keys( + evidence: &SccmEvidence, + profile: &SccmExtractionProfile, +) -> SccmKeyExtractionResult { + extract_keys_with_authority(evidence, profile, true) +} + +fn extract_keys_with_authority( + evidence: &SccmEvidence, + profile: &SccmExtractionProfile, + admitted_profile_authority: bool, +) -> SccmKeyExtractionResult { + let candidates = find_candidates(&evidence.message, profile); + let mut result = SccmKeyExtractionResult { + profile_id: profile.profile_id.clone(), + keys: Vec::new(), + gaps: Vec::new(), + }; + + if let Some(kind) = profile_gap_kind(profile, admitted_profile_authority) { + if candidates.is_empty() { + result.gaps.push(gap_for(kind, profile, evidence, None)); + } else { + result.gaps.extend( + candidates + .iter() + .map(|candidate| gap_for(kind.clone(), profile, evidence, Some(candidate))), + ); + } + return result; + } + + let stable_policy = admitted_profile_authority && is_builtin_stable_policy(profile); + let stable_hierarchy = is_builtin_stable_hierarchy(profile); + if !stable_policy && !stable_hierarchy { + result.gaps.push(gap_for( + SccmExtractionGapKind::ExperimentalProfile, + profile, + evidence, + None, + )); + } + + for candidate in candidates { + let mut key = normalize_key(candidate.kind.clone(), candidate.raw); + // A candidate that normalizes cleanly but carries an out-of-bound value + // is as unusable as one that fails to normalize: the crate's own + // validator rejects it, so emitting it as a key would let the producer + // hand out keys that no longer round trip. Both weigh the same way, and + // both stay visible as a recorded gap. + if key.confidence != SccmKeyConfidence::Exact || !is_bounded_key_value(&key) { + result.gaps.push(gap_for( + SccmExtractionGapKind::MalformedCandidate, + profile, + evidence, + Some(&candidate), + )); + continue; + } + + key.confidence = if stable_policy || stable_hierarchy { + SccmKeyConfidence::Exact + } else { + SccmKeyConfidence::Low + }; + key.extraction_profile_id = Some(profile.profile_id.clone()); + key.evidence = Some(evidence.reference.clone()); + key.start = Some(candidate.start); + key.end = Some(candidate.end); + result.keys.push(key); + } + + result +} + +// The normalized value can outgrow the raw one: normalize_kb_id prepends "KB", +// so a raw that fits the bound can still normalize past it. Both are on the +// wire, so both have to clear the bound the validator applies to both. +fn is_bounded_key_value(key: &SccmCorrelationKey) -> bool { + has_at_most_chars(&key.raw, MAX_SCCM_CORRELATION_KEY_VALUE_CHARS) + && has_at_most_chars(&key.normalized, MAX_SCCM_CORRELATION_KEY_VALUE_CHARS) +} + +fn profile_gap_kind( + profile: &SccmExtractionProfile, + admitted_profile_authority: bool, +) -> Option { + if profile.selected_configmgr_version.is_none() { + return Some(SccmExtractionGapKind::MissingVersion); + } + + match profile.maturity { + SccmExtractionProfileMaturity::Unvalidated => { + Some(SccmExtractionGapKind::UnvalidatedVersion) + } + SccmExtractionProfileMaturity::Experimental if is_builtin_experimental(profile) => None, + SccmExtractionProfileMaturity::Stable + if admitted_profile_authority && is_builtin_stable_policy(profile) => + { + None + } + SccmExtractionProfileMaturity::Stable if is_builtin_stable_hierarchy(profile) => None, + SccmExtractionProfileMaturity::Experimental | SccmExtractionProfileMaturity::Stable => { + Some(SccmExtractionGapKind::UnvalidatedProfile) + } + } +} + +fn is_builtin_stable_hierarchy(profile: &SccmExtractionProfile) -> bool { + profile.profile_id == SCCM_HIERARCHY_KEY_PROFILE_ID + && profile.maturity == SccmExtractionProfileMaturity::Stable + && profile.configmgr_version_prefixes == [HIERARCHY_SYNTHETIC_VERSION] + && profile.validated_artifact_families == [SccmArtifactFamily::Hierarchy] + && profile.selected_configmgr_version.as_deref() == Some(HIERARCHY_SYNTHETIC_VERSION) +} + +fn is_builtin_stable_policy(profile: &SccmExtractionProfile) -> bool { + profile.profile_id == SCCM_POLICY_KEY_PROFILE_ID + && profile.maturity == SccmExtractionProfileMaturity::Stable + && profile.configmgr_version_prefixes == [POLICY_TEST_VERSION] + && profile.validated_artifact_families == [SccmArtifactFamily::ClientPolicy] + && profile.selected_configmgr_version.as_deref() == Some(POLICY_TEST_VERSION) +} + +fn is_builtin_experimental(profile: &SccmExtractionProfile) -> bool { + (is_builtin_experimental_core(profile) + // Preserve the generic public `for_version` contract without treating + // a caller-populated family list as admission authority. + && profile.validated_artifact_families.is_empty()) + || is_registered_synthetic_server_profile(profile) +} + +fn is_registered_synthetic_server_profile(profile: &SccmExtractionProfile) -> bool { + let exact_tuple = match profile.profile_id.as_str() { + SCCM_PROVIDER_SYNTHETIC_KEY_PROFILE_ID => SccmArtifactFamily::Provider, + SCCM_ADMIN_SERVICE_SYNTHETIC_KEY_PROFILE_ID => SccmArtifactFamily::AdminService, + _ => return false, + }; + profile.maturity == SccmExtractionProfileMaturity::Experimental + && profile.configmgr_version_prefixes == [SYNTHETIC_VERSION] + && profile.validated_artifact_families == [exact_tuple] + && profile.selected_configmgr_version.as_deref() == Some(SYNTHETIC_VERSION) +} + +fn is_builtin_experimental_core(profile: &SccmExtractionProfile) -> bool { + profile.profile_id == SCCM_EXPERIMENTAL_KEY_PROFILE_ID + && profile.maturity == SccmExtractionProfileMaturity::Experimental + && profile.configmgr_version_prefixes == [EXPERIMENTAL_VERSION_PREFIX] + && profile + .selected_configmgr_version + .as_deref() + .is_some_and(|version| { + is_canonical_configmgr_version(version) + && version.starts_with(EXPERIMENTAL_VERSION_PREFIX) + }) +} + +fn is_canonical_configmgr_version(version: &str) -> bool { + let mut component_count = 0; + for component in version.split('.') { + component_count += 1; + if component.is_empty() || !component.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + } + component_count == 4 +} + +fn find_candidates<'a>(message: &'a str, profile: &SccmExtractionProfile) -> Vec> { + let patterns = if is_builtin_stable_hierarchy(profile) { + hierarchy_key_patterns() + } else { + key_patterns() + }; + let mut candidates = patterns + .iter() + .flat_map(|pattern| { + pattern.regex.captures_iter(message).filter_map(|captures| { + let matched = captures.get(0)?; + if !is_key_label_start(message, matched.start()) { + return None; + } + let value = captures.name("value")?; + let raw_end = candidate_token_end(message, value.end()); + let raw = &message[value.start()..raw_end]; + let start = message[..value.start()].encode_utf16().count(); + Some(KeyCandidate { + kind: pattern.kind.clone(), + raw, + start, + end: start + raw.encode_utf16().count(), + }) + }) + }) + .collect::>(); + + candidates.sort_by_key(|candidate| { + ( + candidate.start, + key_kind_order(&candidate.kind), + candidate.end, + ) + }); + candidates.dedup_by(|right, left| { + right.kind == left.kind + && right.raw == left.raw + && right.start == left.start + && right.end == left.end + }); + candidates +} + +fn is_key_label_start(message: &str, label_start: usize) -> bool { + label_start == 0 + || message[..label_start] + .chars() + .next_back() + .is_some_and(is_key_token_boundary) +} + +fn candidate_token_end(message: &str, captured_end: usize) -> usize { + message[captured_end..] + .char_indices() + .find_map(|(offset, character)| { + is_key_token_boundary(character).then_some(captured_end + offset) + }) + .unwrap_or(message.len()) +} + +fn is_key_token_boundary(character: char) -> bool { + character.is_whitespace() || matches!(character, ',' | ';' | '&') +} + +fn gap_for( + kind: SccmExtractionGapKind, + profile: &SccmExtractionProfile, + evidence: &SccmEvidence, + candidate: Option<&KeyCandidate<'_>>, +) -> SccmExtractionGap { + SccmExtractionGap { + kind, + profile_id: profile.profile_id.clone(), + selected_configmgr_version: profile.selected_configmgr_version.clone(), + candidate_kind: candidate.map(|candidate| candidate.kind.clone()), + candidate_raw: candidate.map(|candidate| candidate.raw.to_owned()), + evidence: evidence.reference.clone(), + } +} + +fn normalize_value(kind: &SccmCorrelationKeyKind, raw: &str) -> (String, SccmKeyConfidence) { + let trimmed = raw.trim(); + let normalized = match kind { + SccmCorrelationKeyKind::AssignmentId + | SccmCorrelationKeyKind::PolicyId + | SccmCorrelationKeyKind::ClientGuid + | SccmCorrelationKeyKind::UpdateId + | SccmCorrelationKeyKind::BitsJobId + | SccmCorrelationKeyKind::TaskSequenceExecutionId + | SccmCorrelationKeyKind::RequestId + | SccmCorrelationKeyKind::TopicId => normalize_guid(trimmed), + SccmCorrelationKeyKind::PackageId => { + is_fixed_alphanumeric(trimmed, 8).then(|| trimmed.to_ascii_uppercase()) + } + SccmCorrelationKeyKind::ContentId => { + is_opaque_id(trimmed).then(|| trimmed.to_ascii_lowercase()) + } + SccmCorrelationKeyKind::SiteCode => { + is_fixed_alphanumeric(trimmed, 3).then(|| trimmed.to_ascii_uppercase()) + } + SccmCorrelationKeyKind::ServerHost => normalize_server_host(trimmed), + SccmCorrelationKeyKind::CiId | SccmCorrelationKeyKind::StateMessageId => { + normalize_decimal(trimmed) + } + SccmCorrelationKeyKind::KbId => normalize_kb_id(trimmed), + SccmCorrelationKeyKind::HierarchyMessageId => normalize_prefixed_opaque_id(trimmed, "msg-"), + SccmCorrelationKeyKind::HierarchyLinkId => normalize_prefixed_opaque_id(trimmed, "link-"), + SccmCorrelationKeyKind::InventoryCycleId + | SccmCorrelationKeyKind::ReportId + | SccmCorrelationKeyKind::ComplianceCiId + | SccmCorrelationKeyKind::BaselineId + | SccmCorrelationKeyKind::ComplianceStateId + | SccmCorrelationKeyKind::MeteringCycleId + | SccmCorrelationKeyKind::RuleId => is_opaque_id(trimmed).then(|| trimmed.to_owned()), + SccmCorrelationKeyKind::ResourceHandle => normalize_resource_handle(trimmed), + }; + + normalized.map_or_else( + || (trimmed.to_ascii_lowercase(), SccmKeyConfidence::Low), + |normalized| (normalized, SccmKeyConfidence::Exact), + ) +} + +fn normalize_prefixed_opaque_id(value: &str, prefix: &str) -> Option { + value.strip_prefix(prefix).and_then(|suffix| { + (!suffix.is_empty() + && suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))) + .then(|| value.to_owned()) + }) +} + +fn normalize_guid(raw: &str) -> Option { + let without_prefix = raw + .get(..5) + .filter(|prefix| prefix.eq_ignore_ascii_case("guid:")) + .map_or(raw, |_| &raw[5..]); + let without_braces = match ( + without_prefix.strip_prefix('{'), + without_prefix.strip_suffix('}'), + ) { + (Some(_), None) | (None, Some(_)) => return None, + (Some(_), Some(_)) => &without_prefix[1..without_prefix.len() - 1], + (None, None) => without_prefix, + }; + let bytes = without_braces.as_bytes(); + let valid = bytes.len() == 36 + && bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => byte.is_ascii_hexdigit(), + }); + valid.then(|| without_braces.to_ascii_lowercase()) +} + +fn is_fixed_alphanumeric(value: &str, width: usize) -> bool { + value.len() == width && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +fn is_opaque_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) + && value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) +} + +fn normalize_server_host(value: &str) -> Option { + let host = value.strip_suffix('.').unwrap_or(value); + let valid = !host.is_empty() + && host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }); + valid.then(|| host.to_ascii_lowercase()) +} + +fn normalize_decimal(value: &str) -> Option { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let normalized = value.trim_start_matches('0'); + Some(if normalized.is_empty() { + "0".to_owned() + } else { + normalized.to_owned() + }) +} + +fn normalize_kb_id(value: &str) -> Option { + let digits = value + .get(..2) + .filter(|prefix| prefix.eq_ignore_ascii_case("kb")) + .map_or(value, |_| &value[2..]); + normalize_decimal(digits).map(|digits| format!("KB{digits}")) +} + +fn normalize_resource_handle(value: &str) -> Option { + let valid = value.len() <= 128 + && value.starts_with("safe:") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'.' | b'-')); + valid.then(|| value.to_owned()) +} + +fn key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { + match kind { + SccmCorrelationKeyKind::AssignmentId => 0, + SccmCorrelationKeyKind::PolicyId => 1, + SccmCorrelationKeyKind::ClientGuid => 2, + SccmCorrelationKeyKind::PackageId => 3, + SccmCorrelationKeyKind::ContentId => 4, + SccmCorrelationKeyKind::SiteCode => 5, + SccmCorrelationKeyKind::ServerHost => 6, + SccmCorrelationKeyKind::CiId => 7, + SccmCorrelationKeyKind::UpdateId => 8, + SccmCorrelationKeyKind::KbId => 9, + SccmCorrelationKeyKind::BitsJobId => 10, + SccmCorrelationKeyKind::TaskSequenceExecutionId => 11, + SccmCorrelationKeyKind::RequestId => 12, + SccmCorrelationKeyKind::TopicId => 13, + SccmCorrelationKeyKind::StateMessageId => 14, + SccmCorrelationKeyKind::HierarchyMessageId => 15, + SccmCorrelationKeyKind::HierarchyLinkId => 16, + SccmCorrelationKeyKind::InventoryCycleId => 17, + SccmCorrelationKeyKind::ReportId => 18, + SccmCorrelationKeyKind::ResourceHandle => 19, + SccmCorrelationKeyKind::ComplianceCiId => 20, + SccmCorrelationKeyKind::BaselineId => 21, + SccmCorrelationKeyKind::ComplianceStateId => 22, + SccmCorrelationKeyKind::MeteringCycleId => 23, + SccmCorrelationKeyKind::RuleId => 24, + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs new file mode 100644 index 000000000..210f64dcb --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -0,0 +1,20 @@ +pub mod catalog; +pub mod client; +pub mod correlation; +mod evidence; +mod findings; +mod ingest; +mod keys; +pub mod models; +mod rotation; +pub mod server; +mod signals; + +pub use catalog::*; +pub use client::*; +pub use correlation::*; +pub use findings::*; +pub use ingest::*; +pub use keys::*; +pub use models::*; +pub use signals::*; diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs new file mode 100644 index 000000000..0e427b0ce --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -0,0 +1,461 @@ +use serde::de::Error as _; +use serde::ser::{Error as _, SerializeStruct}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use super::catalog::SccmArtifactFamily; +use super::rotation::{is_canonical_rotation_number, is_canonical_rotation_timestamp}; + +pub const SCCM_DIAGNOSTICS_SCHEMA_VERSION: u32 = 1; +const INVALID_SCCM_ROLE_MESSAGE: &str = + "InvalidRole: unknown SCCM role must be canonical and must not shadow a declared role"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCoverageState { + Captured, + Absent, + AccessDenied, + Capped, + Skipped, + Unsupported, + ParseFailed, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SccmRole { + Client, + SiteServer, + ManagementPoint, + DistributionPoint, + SoftwareUpdatePoint, + WsUs, + Provider, + AdminService, + Unknown(String), +} + +impl SccmRole { + fn serialized_name(&self) -> &str { + match self { + Self::Client => "client", + Self::SiteServer => "siteServer", + Self::ManagementPoint => "managementPoint", + Self::DistributionPoint => "distributionPoint", + Self::SoftwareUpdatePoint => "softwareUpdatePoint", + Self::WsUs => "wsUs", + Self::Provider => "provider", + Self::AdminService => "adminService", + Self::Unknown(value) => value, + } + } + + pub(crate) fn has_canonical_serialized_form(&self) -> bool { + match self { + Self::Unknown(value) => { + !value.is_empty() + && value.trim() == value + && !matches!( + value.as_str(), + "client" + | "siteServer" + | "managementPoint" + | "distributionPoint" + | "softwareUpdatePoint" + | "wsUs" + | "provider" + | "adminService" + ) + } + _ => true, + } + } +} + +impl Serialize for SccmRole { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if !self.has_canonical_serialized_form() { + return Err(S::Error::custom(INVALID_SCCM_ROLE_MESSAGE)); + } + serializer.serialize_str(self.serialized_name()) + } +} + +impl<'de> Deserialize<'de> for SccmRole { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let role = match String::deserialize(deserializer)? { + value if value == "client" => Self::Client, + value if value == "siteServer" => Self::SiteServer, + value if value == "managementPoint" => Self::ManagementPoint, + value if value == "distributionPoint" => Self::DistributionPoint, + value if value == "softwareUpdatePoint" => Self::SoftwareUpdatePoint, + value if value == "wsUs" => Self::WsUs, + value if value == "provider" => Self::Provider, + value if value == "adminService" => Self::AdminService, + value => Self::Unknown(value), + }; + if !role.has_canonical_serialized_form() { + return Err(D::Error::custom(INVALID_SCCM_ROLE_MESSAGE)); + } + Ok(role) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmFindingClass { + Symptom, + ConfirmedFailure, + Recovered, + ContradictoryEvidence, + BlockedOrDeferred, + LikelyContributor, + InsufficientEvidence, +} + +impl SccmFindingClass { + pub fn as_str(&self) -> &'static str { + match self { + Self::Symptom => "symptom", + Self::ConfirmedFailure => "confirmedFailure", + Self::Recovered => "recovered", + Self::ContradictoryEvidence => "contradictoryEvidence", + Self::BlockedOrDeferred => "blockedOrDeferred", + Self::LikelyContributor => "likelyContributor", + Self::InsufficientEvidence => "insufficientEvidence", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTimeOrderingState { + NormalizedUtc, + OffsetMissing, + OffsetInvalid, + TimestampMissing, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTimestamp { + pub original_display: Option, + pub offset_minutes: Option, + pub utc_millis: Option, + pub ordering_state: SccmTimeOrderingState, +} + +// Serialize and Deserialize are implemented by hand in findings.rs, where the +// deny_unknown_fields wire struct and validate_evidence_reference live, so a +// reference cannot cross a public wire boundary through a door the finding +// contract does not guard. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SccmEvidenceRef { + pub artifact_id: String, + pub entry_id: String, + pub line_start: Option, + pub line_end: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSensitiveHandle { + pub scheme: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmEvidence { + pub evidence_id: String, + pub reference: SccmEvidenceRef, + pub role: SccmRole, + pub component: Option, + pub ccm_source_file: Option, + pub message: String, + pub timestamp: SccmTimestamp, + pub execution_context: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCorrelationKeyKind { + AssignmentId, + PolicyId, + ClientGuid, + PackageId, + ContentId, + SiteCode, + ServerHost, + CiId, + UpdateId, + KbId, + BitsJobId, + TaskSequenceExecutionId, + RequestId, + TopicId, + StateMessageId, + HierarchyMessageId, + HierarchyLinkId, + InventoryCycleId, + ReportId, + ResourceHandle, + ComplianceCiId, + BaselineId, + ComplianceStateId, + MeteringCycleId, + RuleId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmKeyConfidence { + Low, + Strong, + Exact, +} + +// Serialize and Deserialize are implemented by hand in findings.rs so that a +// standalone key clears validate_correlation_key_evidence, including the +// confidence gate that keeps every key at Low while no stable extraction +// profile is registered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SccmCorrelationKey { + pub kind: SccmCorrelationKeyKind, + pub raw: String, + pub normalized: String, + pub confidence: SccmKeyConfidence, + pub extraction_profile_id: Option, + pub evidence: Option, + pub start: Option, + pub end: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmExtractionProfileMaturity { + Unvalidated, + Experimental, + Stable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmExtractionProfile { + pub profile_id: String, + pub configmgr_version_prefixes: Vec, + pub validated_artifact_families: Vec, + pub selected_configmgr_version: Option, + pub maturity: SccmExtractionProfileMaturity, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmExtractionGapKind { + MissingVersion, + UnvalidatedVersion, + UnvalidatedProfile, + ExperimentalProfile, + MalformedCandidate, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmExtractionGap { + pub kind: SccmExtractionGapKind, + pub profile_id: String, + pub selected_configmgr_version: Option, + pub candidate_kind: Option, + pub candidate_raw: Option, + pub evidence: SccmEvidenceRef, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmKeyExtractionResult { + pub profile_id: String, + pub keys: Vec, + pub gaps: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SccmRotation { + Current, + LoUnderscore, + Numbered(u32), + Timestamped(String), + Unknown(SccmUnknownRotation), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmUnknownRotation { + pub kind: String, + pub value: Option, +} + +impl Serialize for SccmRotation { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Numbered(value) if !is_canonical_rotation_number(*value) => { + return Err(S::Error::custom( + "numbered rotation value must be a nonzero u32", + )); + } + Self::Timestamped(value) if !is_canonical_rotation_timestamp(value) => { + return Err(S::Error::custom( + "timestamped rotation value must use canonical YYYYMMDD-HHMMSS", + )); + } + _ => {} + } + + let field_count = match self { + Self::Current | Self::LoUnderscore => 1, + Self::Numbered(_) | Self::Timestamped(_) => 2, + Self::Unknown(unknown) => 1 + usize::from(unknown.value.is_some()), + }; + let mut state = serializer.serialize_struct("SccmRotation", field_count)?; + + match self { + Self::Current => state.serialize_field("kind", "current")?, + Self::LoUnderscore => state.serialize_field("kind", "loUnderscore")?, + Self::Numbered(value) => { + state.serialize_field("kind", "numbered")?; + state.serialize_field("value", value)?; + } + Self::Timestamped(value) => { + state.serialize_field("kind", "timestamped")?; + state.serialize_field("value", value)?; + } + Self::Unknown(unknown) => { + state.serialize_field("kind", &unknown.kind)?; + if let Some(value) = &unknown.value { + state.serialize_field("value", value)?; + } + } + } + + state.end() + } +} + +impl<'de> Deserialize<'de> for SccmRotation { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let Value::Object(mut fields) = Value::deserialize(deserializer)? else { + return Err(serde::de::Error::custom( + "SCCM rotation must be a tagged object", + )); + }; + let kind = fields + .remove("kind") + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| serde::de::Error::custom("SCCM rotation kind must be a string"))?; + let value = fields.remove("value"); + + if !fields.is_empty() { + return Err(serde::de::Error::custom( + "SCCM rotation contains unsupported fields", + )); + } + + match kind.as_str() { + "current" => { + require_no_rotation_value::(&kind, value)?; + Ok(Self::Current) + } + "loUnderscore" => { + require_no_rotation_value::(&kind, value)?; + Ok(Self::LoUnderscore) + } + "numbered" => { + let number = value + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| { + serde::de::Error::custom("numbered rotation value must be a u32") + })?; + if !is_canonical_rotation_number(number) { + return Err(serde::de::Error::custom( + "numbered rotation value must be a nonzero u32", + )); + } + Ok(Self::Numbered(number)) + } + "timestamped" => { + let timestamp = value + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| { + serde::de::Error::custom("timestamped rotation value must be a string") + })?; + if !is_canonical_rotation_timestamp(×tamp) { + return Err(serde::de::Error::custom( + "timestamped rotation value must use canonical YYYYMMDD-HHMMSS", + )); + } + Ok(Self::Timestamped(timestamp)) + } + _ => Ok(Self::Unknown(SccmUnknownRotation { kind, value })), + } + } +} + +fn require_no_rotation_value(kind: &str, value: Option) -> Result<(), E> +where + E: serde::de::Error, +{ + if value.is_some() { + return Err(E::custom(format!( + "{kind} rotation must not contain a value" + ))); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmArtifact { + pub artifact_id: String, + pub display_name: String, + pub original_path: Option, + pub host: Option, + pub role: SccmRole, + pub configmgr_version: Option, + pub collected_at_utc: Option, + pub rotation: SccmRotation, + pub coverage: SccmCoverageState, + pub encoding: Option, +} + +impl SccmArtifact { + pub fn missing( + artifact_id: impl Into, + display_name: impl Into, + role: SccmRole, + coverage: SccmCoverageState, + ) -> Self { + Self { + artifact_id: artifact_id.into(), + display_name: display_name.into(), + original_path: None, + host: None, + role, + configmgr_version: None, + collected_at_utc: None, + rotation: SccmRotation::Current, + coverage, + encoding: None, + } + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/rotation.rs b/crates/cmtraceopen-parser/src/sccm/rotation.rs new file mode 100644 index 000000000..5b63d8acc --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/rotation.rs @@ -0,0 +1,26 @@ +use chrono::{NaiveDateTime, Timelike}; + +pub(super) fn is_canonical_rotation_number(value: u32) -> bool { + value != 0 +} + +pub(super) fn parse_canonical_rotation_number(value: &str) -> Option { + let first = value.as_bytes().first()?; + if *first == b'0' || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + value + .parse::() + .ok() + .filter(|value| is_canonical_rotation_number(*value)) +} + +/// Timestamped rotations use exactly `YYYYMMDD-HHMMSS`. +pub(super) fn is_canonical_rotation_timestamp(value: &str) -> bool { + value.len() == "YYYYMMDD-HHMMSS".len() + && NaiveDateTime::parse_from_str(value, "%Y%m%d-%H%M%S").is_ok_and(|timestamp| { + timestamp.nanosecond() < 1_000_000_000 + && timestamp.format("%Y%m%d-%H%M%S").to_string() == value + }) +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/mod.rs b/crates/cmtraceopen-parser/src/sccm/server/mod.rs new file mode 100644 index 000000000..0d034fd34 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/mod.rs @@ -0,0 +1 @@ +pub mod windows; diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs new file mode 100644 index 000000000..0edb0e109 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs @@ -0,0 +1,251 @@ +use crate::sccm::{classify_artifact_name, SccmArtifactFamily, SccmRole, SccmSourceCatalogEntry}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SccmServerSourceKind { + CcmLog, + IisW3c, + StructuredSupplement, + ProfileDefined, +} + +#[derive(Debug)] +pub struct SccmServerSourceSpec { + pub source_id: &'static str, + pub producer_role: SccmRole, + pub workflow_subject_role: Option, + pub logical_names: &'static [&'static str], + /// Exact basename required for a bounded, profile-defined supplemental + /// source. CCM sources use `logical_names` instead. + pub explicit_basename: Option<&'static str>, + pub source_kind: SccmServerSourceKind, + pub supplemental: bool, +} + +const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ + SccmServerSourceSpec { + source_id: "server-sitecomp", + producer_role: SccmRole::SiteServer, + workflow_subject_role: None, + logical_names: &["sitecomp", "hman"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-status", + producer_role: SccmRole::SiteServer, + workflow_subject_role: None, + logical_names: &["statmgr", "statesys"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-hierarchy-control", + producer_role: SccmRole::SiteServer, + workflow_subject_role: None, + logical_names: &["replmgr", "rcmctrl"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-hierarchy-transfer", + producer_role: SccmRole::SiteServer, + workflow_subject_role: None, + logical_names: &["sender", "despool"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-mp-auth", + producer_role: SccmRole::ManagementPoint, + workflow_subject_role: None, + logical_names: &["mpGetAuth", "mpCliReg", "mpRegistrationManager"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-mp-policy", + producer_role: SccmRole::ManagementPoint, + workflow_subject_role: None, + logical_names: &["mpGetPolicy", "mpLocation"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-mp-policy", + producer_role: SccmRole::SiteServer, + workflow_subject_role: Some(SccmRole::ManagementPoint), + logical_names: &["mpcontrol"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-mp-iis", + producer_role: SccmRole::ManagementPoint, + workflow_subject_role: None, + logical_names: &[], + explicit_basename: None, + source_kind: SccmServerSourceKind::IisW3c, + supplemental: true, + }, + SccmServerSourceSpec { + source_id: "server-dp-distribution", + producer_role: SccmRole::SiteServer, + workflow_subject_role: Some(SccmRole::DistributionPoint), + logical_names: &["distmgr", "pkgXferMgr"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-dp-distribution", + producer_role: SccmRole::DistributionPoint, + workflow_subject_role: None, + logical_names: &["smsDpProv", "pullDp"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-dp-serve", + producer_role: SccmRole::DistributionPoint, + workflow_subject_role: None, + logical_names: &["smsDpmon"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: true, + }, + SccmServerSourceSpec { + source_id: "server-sup-sync", + producer_role: SccmRole::SiteServer, + workflow_subject_role: Some(SccmRole::SoftwareUpdatePoint), + logical_names: &["wcm", "wsyncmgr"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-sup-sync", + producer_role: SccmRole::SoftwareUpdatePoint, + workflow_subject_role: Some(SccmRole::SoftwareUpdatePoint), + logical_names: &["wsusCtrl", "supSetup"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-sup-wsus", + producer_role: SccmRole::WsUs, + workflow_subject_role: Some(SccmRole::SoftwareUpdatePoint), + logical_names: &[], + explicit_basename: Some("WsusHealth.json"), + source_kind: SccmServerSourceKind::ProfileDefined, + supplemental: true, + }, + SccmServerSourceSpec { + source_id: "server-provider", + producer_role: SccmRole::Provider, + workflow_subject_role: Some(SccmRole::Provider), + logical_names: &["smsprov"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-admin-service", + producer_role: SccmRole::AdminService, + workflow_subject_role: Some(SccmRole::AdminService), + logical_names: &["adminService"], + explicit_basename: None, + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-admin-service-iis", + producer_role: SccmRole::AdminService, + workflow_subject_role: Some(SccmRole::AdminService), + logical_names: &[], + explicit_basename: None, + source_kind: SccmServerSourceKind::IisW3c, + supplemental: true, + }, +]; + +pub fn declared_server_source_catalog() -> &'static [SccmServerSourceSpec] { + SERVER_SOURCE_SPECS +} + +pub(crate) fn classify_declared_server_source( + source_id: &str, + producer_role: &SccmRole, + workflow_subject_role: Option<&SccmRole>, + source_kind: &str, + basename: &str, +) -> Option<( + &'static SccmServerSourceSpec, + Option, +)> { + let spec = SERVER_SOURCE_SPECS.iter().find(|spec| { + spec.source_id == source_id + && &spec.producer_role == producer_role + && spec.workflow_subject_role.as_ref() == workflow_subject_role + && source_kind_matches(spec.source_kind, source_kind) + })?; + + if spec.source_kind != SccmServerSourceKind::CcmLog { + if spec + .explicit_basename + .is_some_and(|declared| declared != basename) + { + return None; + } + return Some((spec, None)); + } + + let classified = classify_artifact_name(basename, producer_role.clone()); + if !classified.supported_for_diagnosis + || !spec + .logical_names + .iter() + .any(|logical_name| *logical_name == classified.logical_name) + { + return None; + } + + Some((spec, Some(classified))) +} + +pub(crate) fn expected_family(source_id: &str) -> Option { + Some(match source_id { + "server-sitecomp" => SccmArtifactFamily::SiteComponent, + "server-status" => SccmArtifactFamily::SiteStatus, + "server-hierarchy-control" | "server-hierarchy-transfer" => SccmArtifactFamily::Hierarchy, + "server-mp-auth" | "server-mp-policy" | "server-mp-iis" => { + SccmArtifactFamily::ManagementPoint + } + "server-dp-distribution" | "server-dp-serve" => SccmArtifactFamily::DistributionPoint, + "server-sup-sync" | "server-sup-wsus" => SccmArtifactFamily::SoftwareUpdatePoint, + "server-provider" => SccmArtifactFamily::Provider, + "server-admin-service" | "server-admin-service-iis" => SccmArtifactFamily::AdminService, + _ => return None, + }) +} + +fn source_kind_matches(expected: SccmServerSourceKind, actual: &str) -> bool { + matches!( + (expected, actual), + (SccmServerSourceKind::CcmLog, "ccmLog") + | (SccmServerSourceKind::IisW3c, "iisW3c") + | ( + SccmServerSourceKind::StructuredSupplement, + "structuredSupplement" + ) + | (SccmServerSourceKind::ProfileDefined, "profileDefined") + ) +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs new file mode 100644 index 000000000..ea0c89bb3 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs @@ -0,0 +1,1603 @@ +//! Canonical-intake adapter for Distribution Point source evidence. +//! +//! The source adapter admits only declared DP CCM sources from normalized, +//! integrity-bound server intake. The content reducer then applies one exact +//! versioned fact profile to source-local package lifecycle evidence. It makes +//! no client-impact or cross-side causal claim. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use thiserror::Error; + +use crate::models::log_entry::Severity; +use crate::sccm::{ + classify_artifact_name, SccmArtifactFamily, SccmArtifactRequest, SccmCoverageState, + SccmEvidence, SccmEvidenceRef, SccmRole, SccmRotation, SccmTimeOrderingState, SccmTimestamp, +}; + +use super::{ + declared_server_source_catalog, SccmServerArtifactAssessment, SccmServerCoverage, + SccmServerIntakeAssessment, SccmServerSourceKind, +}; + +pub const SCCM_DISTRIBUTION_POINT_ANALYSIS_SCHEMA_VERSION: u32 = 1; +pub const SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_ID: &str = "sccm-dp-intake-envelope"; +pub const SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_VERSION: u32 = 1; +pub const SCCM_DISTRIBUTION_POINT_SOURCE_ID: &str = "server-dp-distribution"; +pub const SCCM_DISTRIBUTION_POINT_CONTENT_ANALYSIS_SCHEMA_VERSION: u32 = 1; +pub const SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_ID: &str = "dp-server-5.00.test-v1"; +pub const SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_VERSION: u32 = 1; +const SCCM_DISTRIBUTION_POINT_CONTENT_SOURCE_VERSION: &str = "5.00.TEST.0001"; +const SCCM_DISTRIBUTION_POINT_INTAKE_AUTHORITY_REASON: &str = + "Canonical server intake authority could not be verified."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDistributionPointWorkflow { + DistributionPointContent, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDistributionPointProfile { + pub id: String, + pub version: u32, + pub stability: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDistributionPointSourceObservation { + pub artifact_id: String, + pub producer_role: SccmRole, + pub producer_host_handle: Option, + pub workflow_subject_role: Option, + pub workflow_subject_handle: Option, + pub source_id: String, + pub source_version: Option, + pub rotation: Option, + pub rotation_lineage_handle: String, + pub evidence: SccmEvidenceRef, + pub timestamp: SccmTimestamp, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDistributionPointCoverageGap { + pub source_id: String, + pub producer_role: Option, + pub workflow_subject_role: Option, + pub state: Option, + pub artifact_ids: Vec, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDistributionPointAnalysis { + pub schema_version: u32, + pub workflow: SccmDistributionPointWorkflow, + pub profile: SccmDistributionPointProfile, + pub source_observations: Vec, + pub coverage_gaps: Vec, + pub artifact_requests: Vec, + pub cross_side_correlation_performed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDistributionPointContentPhase { + ReceiveContent, + Distribute, + Transfer, + Validate, + MakeAvailable, + ServeOrReport, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDistributionPointContentState { + Succeeded, + Failed, + Retrying, + Blocked, + Deferred, + Contradictory, + Incomplete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDistributionPointContentClassification { + Success, + ConfirmedFailure, + BlockedOrDeferred, + ContradictoryEvidence, + InsufficientEvidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDistributionPointContentConfidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDistributionPointContentScope { + DistributionPointContent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDistributionPointContentDisposition { + Succeeded, + Failed, + Retrying, + Blocked, + Deferred, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDistributionPointContentKey { + pub package_id: String, + pub content_id: String, + pub content_version: u32, + pub topology_site_handle: String, + pub site_code: String, + pub distribution_point_handle: String, + pub extraction_profile_id: String, + pub extraction_profile_version: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDistributionPointContentObservation { + pub phase: SccmDistributionPointContentPhase, + pub disposition: SccmDistributionPointContentDisposition, + pub terminal: bool, + pub source_id: String, + pub timestamp: SccmTimestamp, + pub evidence: SccmEvidenceRef, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDistributionPointContentTransaction { + pub transaction_id: String, + pub key: SccmDistributionPointContentKey, + pub state: SccmDistributionPointContentState, + pub classification: SccmDistributionPointContentClassification, + pub confidence: SccmDistributionPointContentConfidence, + pub severity: Severity, + pub scope: SccmDistributionPointContentScope, + pub last_proven_phase: Option, + pub stop_phase: Option, + pub recovered: bool, + pub content_version_mismatch: bool, + pub evidence: Vec, + pub terminal_evidence: Vec, + pub next_artifact: Option, + pub observations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDistributionPointContentAnalysis { + pub schema_version: u32, + pub workflow: SccmDistributionPointWorkflow, + pub profile: SccmDistributionPointProfile, + pub transactions: Vec, + pub coverage_gaps: Vec, + pub artifact_requests: Vec, + pub cross_side_correlation_performed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SccmDistributionPointContentIntakeError { + #[error("canonical server intake authority could not be verified")] + IntakeAuthority, + #[error("Distribution Point topology is not compatible with the admitted profile")] + Topology, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct DistributionPointFactKey { + package_id: String, + content_id: String, + content_version: u32, + site_code: String, + distribution_point_handle: String, +} + +#[derive(Debug, Clone)] +struct DistributionPointFact { + key: DistributionPointFactKey, + phase: SccmDistributionPointContentPhase, + disposition: SccmDistributionPointContentDisposition, + terminal: bool, + source_id: String, + reference: SccmEvidenceRef, + timestamp: SccmTimestamp, +} + +/// Private canonical transaction envelope. Facts only originate from an +/// integrity-bound server intake assessment; callers cannot construct or +/// submit source facts directly. +#[derive(Debug)] +struct DistributionPointTransactionEnvelope { + topology_site_handle: String, + key: DistributionPointFactKey, + facts: Vec, +} + +/// Project only complete, profile-eligible logical CCM records from the +/// canonical server intake. The output is source-local and intentionally does +/// not interpret message text as a package/content success or failure. +pub fn analyze_distribution_point( + intake: &SccmServerIntakeAssessment, +) -> SccmDistributionPointAnalysis { + if !intake.adapter_authority_is_intake_bound() || !intake.topology_authority_is_intake_bound() { + return intake_authority_invalid_analysis(); + } + + let artifact_id_counts = + intake + .artifacts + .iter() + .fold(BTreeMap::<&str, usize>::new(), |mut counts, artifact| { + *counts.entry(artifact.artifact_id.as_str()).or_default() += 1; + counts + }); + let evidence_by_artifact = intake.evidence.iter().fold( + BTreeMap::<&str, Vec<&SccmEvidence>>::new(), + |mut grouped, evidence| { + grouped + .entry(evidence.reference.artifact_id.as_str()) + .or_default() + .push(evidence); + grouped + }, + ); + let artifacts = intake + .artifacts + .iter() + .filter(|artifact| { + artifact_id_counts + .get(artifact.artifact_id.as_str()) + .is_some_and(|count| *count == 1) + && is_dp_distribution_artifact(artifact) + && artifact_metadata_is_congruent(intake, artifact, &artifact_id_counts) + && evidence_by_artifact + .get(artifact.artifact_id.as_str()) + .is_some_and(|evidence| canonical_evidence_set(artifact, evidence)) + }) + .map(|artifact| (artifact.artifact_id.as_str(), artifact)) + .collect::>(); + + let mut source_observations = intake + .evidence + .iter() + .filter_map(|evidence| { + let artifact = artifacts.get(evidence.reference.artifact_id.as_str())?; + admitted_for_source_observation(artifact, evidence).then(|| { + SccmDistributionPointSourceObservation { + artifact_id: artifact.artifact_id.clone(), + producer_role: artifact.producer_role.clone(), + producer_host_handle: artifact.producer_host_handle.clone(), + workflow_subject_role: artifact.workflow_subject_role.clone(), + workflow_subject_handle: artifact.workflow_subject_handle.clone(), + source_id: artifact.source_id.clone(), + source_version: artifact.source_version.clone(), + rotation: artifact.rotation.clone(), + rotation_lineage_handle: artifact.rotation_lineage_handle.clone(), + evidence: evidence.reference.clone(), + timestamp: evidence.timestamp.clone(), + } + }) + }) + .collect::>(); + source_observations.sort_by(|left, right| { + source_observation_sort_key(left).cmp(&source_observation_sort_key(right)) + }); + + let mut coverage_gaps = coverage_gaps(intake, &source_observations); + coverage_gaps.sort_by_key(coverage_gap_sort_key); + + let artifact_requests = artifact_requests(&coverage_gaps); + + SccmDistributionPointAnalysis { + schema_version: SCCM_DISTRIBUTION_POINT_ANALYSIS_SCHEMA_VERSION, + workflow: SccmDistributionPointWorkflow::DistributionPointContent, + profile: SccmDistributionPointProfile { + id: SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_ID.to_owned(), + version: SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_VERSION, + stability: "experimental".to_owned(), + }, + source_observations, + coverage_gaps, + artifact_requests, + cross_side_correlation_performed: false, + } +} + +/// Reduce the approved DP package profile from canonical server intake. +/// Source messages are evidence only after their artifact, coverage, topology, +/// version, role, and physical line authority have passed the sealed adapter. +pub fn analyze_distribution_point_content_from_server_intake( + intake: &SccmServerIntakeAssessment, +) -> Result { + if !intake.adapter_authority_is_intake_bound() || !intake.topology_authority_is_intake_bound() { + return Err(SccmDistributionPointContentIntakeError::IntakeAuthority); + } + if !intake + .topology + .roles_observed + .contains(&SccmRole::DistributionPoint) + { + return Err(SccmDistributionPointContentIntakeError::Topology); + } + + let bounded = analyze_distribution_point(intake); + let evidence_by_entry_id = intake + .evidence + .iter() + .map(|evidence| (evidence.reference.entry_id.as_str(), evidence)) + .collect::>(); + let artifacts_by_id = intake + .artifacts + .iter() + .map(|artifact| (artifact.artifact_id.as_str(), artifact)) + .collect::>(); + let expected_site_code = + selected_content_profile_site_code(intake.topology.site_handle.as_str()); + let mut facts_by_key = BTreeMap::>::new(); + let mut semantic_gaps = + BTreeMap::<(String, String, String), SccmDistributionPointCoverageGap>::new(); + + for observation in &bounded.source_observations { + let Some(evidence) = evidence_by_entry_id.get(observation.evidence.entry_id.as_str()) + else { + continue; + }; + let Some(artifact) = artifacts_by_id.get(observation.artifact_id.as_str()) else { + continue; + }; + let Some(fact) = + parse_distribution_point_fact(observation, artifact, evidence, expected_site_code) + else { + note_semantic_gap( + &mut semantic_gaps, + artifacts_by_id + .get(observation.artifact_id.as_str()) + .copied(), + ); + continue; + }; + facts_by_key.entry(fact.key.clone()).or_default().push(fact); + } + + let mut transactions = Vec::new(); + for (key, facts) in facts_by_key { + transactions.push(reduce_transaction(DistributionPointTransactionEnvelope { + topology_site_handle: intake.topology.site_handle.clone(), + key, + facts, + })); + } + let mut versions_by_identity = BTreeMap::<(String, String, String), BTreeSet>::new(); + for transaction in &transactions { + versions_by_identity + .entry(( + transaction.key.package_id.clone(), + transaction.key.content_id.clone(), + transaction.key.distribution_point_handle.clone(), + )) + .or_default() + .insert(transaction.key.content_version); + } + for transaction in &mut transactions { + transaction.content_version_mismatch = versions_by_identity + .get(&( + transaction.key.package_id.clone(), + transaction.key.content_id.clone(), + transaction.key.distribution_point_handle.clone(), + )) + .is_some_and(|versions| versions.len() > 1); + } + transactions.sort_by(|left, right| left.key.cmp(&right.key)); + + let mut coverage_gaps = bounded.coverage_gaps; + coverage_gaps.extend(semantic_gaps.into_values().map(|mut gap| { + gap.artifact_ids.sort(); + gap.artifact_ids.dedup(); + gap + })); + coverage_gaps.sort_by(|left, right| { + coverage_gap_sort_key(left) + .cmp(&coverage_gap_sort_key(right)) + .then_with(|| left.reason.cmp(&right.reason)) + }); + coverage_gaps.dedup(); + let mut artifact_requests = artifact_requests(&coverage_gaps); + artifact_requests.extend( + transactions + .iter() + .filter_map(|transaction| transaction.next_artifact.clone()), + ); + artifact_requests.sort_by(|left, right| { + ( + left.logical_id.as_str(), + role_sort_key(&left.role), + left.reason.as_str(), + ) + .cmp(&( + right.logical_id.as_str(), + role_sort_key(&right.role), + right.reason.as_str(), + )) + }); + artifact_requests.dedup_by(|left, right| { + left.logical_id == right.logical_id + && left.role == right.role + && left.reason == right.reason + }); + if !coverage_gaps.is_empty() { + for transaction in &mut transactions { + if transaction.confidence == SccmDistributionPointContentConfidence::High { + transaction.confidence = SccmDistributionPointContentConfidence::Medium; + } + } + } + + Ok(SccmDistributionPointContentAnalysis { + schema_version: SCCM_DISTRIBUTION_POINT_CONTENT_ANALYSIS_SCHEMA_VERSION, + workflow: SccmDistributionPointWorkflow::DistributionPointContent, + profile: SccmDistributionPointProfile { + id: SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_ID.to_owned(), + version: SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_VERSION, + stability: "experimental".to_owned(), + }, + transactions, + coverage_gaps, + artifact_requests, + cross_side_correlation_performed: false, + }) +} + +fn note_semantic_gap( + gaps: &mut BTreeMap<(String, String, String), SccmDistributionPointCoverageGap>, + artifact: Option<&SccmServerArtifactAssessment>, +) { + let Some(artifact) = artifact else { + return; + }; + let key = ( + artifact.source_id.clone(), + role_sort_key(&artifact.producer_role).to_owned(), + artifact + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + ); + gaps.entry(key) + .and_modify(|gap| gap.artifact_ids.push(artifact.artifact_id.clone())) + .or_insert_with(|| SccmDistributionPointCoverageGap { + source_id: artifact.source_id.clone(), + producer_role: Some(artifact.producer_role.clone()), + workflow_subject_role: artifact.workflow_subject_role.clone(), + state: Some(SccmCoverageState::Captured), + artifact_ids: vec![artifact.artifact_id.clone()], + reason: "Captured Distribution Point evidence did not match the selected content profile or complete a supported transaction." + .to_owned(), + }); +} + +fn parse_distribution_point_fact( + observation: &SccmDistributionPointSourceObservation, + artifact: &SccmServerArtifactAssessment, + evidence: &SccmEvidence, + expected_site_code: Option<&str>, +) -> Option { + let phase = exact_message_token(&evidence.message, "Phase").and_then(content_phase)?; + let disposition = exact_message_token(&evidence.message, "Disposition")?; + let terminal = exact_message_token(&evidence.message, "Terminal")?; + let package_id = exact_message_token(&evidence.message, "PackageId")?; + let content_id = exact_message_token(&evidence.message, "ContentId")?; + let content_version = exact_message_token(&evidence.message, "ContentVersion")? + .parse::() + .ok() + .filter(|version| *version > 0)?; + let site_code = exact_message_token(&evidence.message, "SiteCode")?; + let distribution_point_handle = exact_message_token(&evidence.message, "DpHandle")?; + let disposition = content_disposition(disposition)?; + + if observation.source_version.as_deref() != Some(SCCM_DISTRIBUTION_POINT_CONTENT_SOURCE_VERSION) + || !safe_package_id(package_id) + || !safe_content_id(content_id) + || !safe_site_code(site_code) + || expected_site_code != Some(site_code) + || !safe_distribution_point_handle(distribution_point_handle) + || !matches!( + evidence.timestamp.ordering_state, + SccmTimeOrderingState::NormalizedUtc + ) + || evidence.timestamp.offset_minutes.is_none() + || evidence.timestamp.utc_millis.is_none() + { + return None; + } + + let expected_source = match phase { + SccmDistributionPointContentPhase::ReceiveContent + | SccmDistributionPointContentPhase::Distribute => { + observation.producer_role == SccmRole::SiteServer + && artifact.original_basename.as_deref() == Some("distmgr.log") + } + SccmDistributionPointContentPhase::Transfer => { + observation.producer_role == SccmRole::SiteServer + && artifact.original_basename.as_deref() == Some("PkgXferMgr.log") + } + SccmDistributionPointContentPhase::Validate + | SccmDistributionPointContentPhase::MakeAvailable => { + observation.producer_role == SccmRole::DistributionPoint + && artifact.original_basename.as_deref() == Some("SMSDPProv.log") + && observation.source_id == SCCM_DISTRIBUTION_POINT_SOURCE_ID + } + SccmDistributionPointContentPhase::ServeOrReport => { + observation.producer_role == SccmRole::DistributionPoint + && artifact.original_basename.as_deref() == Some("SMSdpmon.log") + && observation.source_id == "server-dp-serve" + } + }; + let observed_distribution_point = match observation.producer_role { + SccmRole::SiteServer => observation.workflow_subject_handle.as_deref(), + SccmRole::DistributionPoint => { + canonical_dp_subject_for_host(observation.producer_host_handle.as_deref()) + } + _ => None, + }; + let expected_terminal = expected_terminal(phase, disposition)?; + if !expected_source + || observed_distribution_point != Some(distribution_point_handle) + || terminal != if expected_terminal { "true" } else { "false" } + { + return None; + } + + Some(DistributionPointFact { + key: DistributionPointFactKey { + package_id: package_id.to_owned(), + content_id: content_id.to_owned(), + content_version, + site_code: site_code.to_owned(), + distribution_point_handle: distribution_point_handle.to_owned(), + }, + phase, + disposition, + terminal: expected_terminal, + source_id: observation.source_id.clone(), + reference: evidence.reference.clone(), + timestamp: evidence.timestamp.clone(), + }) +} + +fn canonical_dp_subject_for_host(host: Option<&str>) -> Option<&'static str> { + match host { + Some("synthetic:host:mp-01") => Some("synthetic:subject:dp-01"), + Some("synthetic:host:wsus-01") => Some("synthetic:subject:dp-02"), + _ => None, + } +} + +fn expected_terminal( + phase: SccmDistributionPointContentPhase, + disposition: SccmDistributionPointContentDisposition, +) -> Option { + use SccmDistributionPointContentDisposition as Disposition; + use SccmDistributionPointContentPhase as Phase; + match (phase, disposition) { + (Phase::ServeOrReport, Disposition::Succeeded) => Some(true), + (Phase::ReceiveContent | Phase::MakeAvailable, Disposition::Succeeded) => Some(false), + (Phase::Distribute | Phase::Transfer | Phase::Validate, Disposition::Succeeded) => { + Some(false) + } + (Phase::Distribute | Phase::Transfer | Phase::Validate, Disposition::Failed) => Some(true), + ( + Phase::Distribute | Phase::Transfer | Phase::Validate, + Disposition::Retrying | Disposition::Blocked | Disposition::Deferred, + ) => Some(false), + _ => None, + } +} + +fn selected_content_profile_site_code(topology_site_handle: &str) -> Option<&'static str> { + match topology_site_handle { + "synthetic:site:lab" => Some("LAB"), + _ => None, + } +} + +fn decisive_fact_order( + fact: &DistributionPointFact, +) -> (i64, SccmDistributionPointContentPhase, &str, &str) { + ( + fact.timestamp + .utc_millis + .expect("admitted DP facts carry normalized UTC"), + fact.phase, + fact.reference.artifact_id.as_str(), + fact.reference.entry_id.as_str(), + ) +} + +fn decisive_missing_phase_fact( + facts: &[DistributionPointFact], + phase: SccmDistributionPointContentPhase, + previous_timestamp: Option, +) -> Option<&DistributionPointFact> { + previous_timestamp + .and_then(|previous| { + facts + .iter() + .filter(|fact| { + fact.phase == phase + && fact + .timestamp + .utc_millis + .is_some_and(|timestamp| timestamp <= previous) + }) + .max_by(|left, right| decisive_fact_order(left).cmp(&decisive_fact_order(right))) + }) + .or_else(|| { + facts + .iter() + .filter(|fact| fact.phase > phase) + .min_by(|left, right| decisive_fact_order(left).cmp(&decisive_fact_order(right))) + }) +} + +fn decisive_downstream_fact( + facts: &[DistributionPointFact], + phase: SccmDistributionPointContentPhase, + current_timestamp: i64, +) -> Option<&DistributionPointFact> { + facts + .iter() + .filter(|fact| { + fact.phase > phase + && fact + .timestamp + .utc_millis + .is_some_and(|timestamp| timestamp > current_timestamp) + }) + .min_by(|left, right| decisive_fact_order(left).cmp(&decisive_fact_order(right))) +} + +fn reduce_transaction( + mut envelope: DistributionPointTransactionEnvelope, +) -> SccmDistributionPointContentTransaction { + envelope.facts.sort_by(|left, right| { + ( + left.phase, + left.timestamp.utc_millis, + left.reference.entry_id.as_str(), + ) + .cmp(&( + right.phase, + right.timestamp.utc_millis, + right.reference.entry_id.as_str(), + )) + }); + let required_phases = [ + SccmDistributionPointContentPhase::ReceiveContent, + SccmDistributionPointContentPhase::Distribute, + SccmDistributionPointContentPhase::Transfer, + SccmDistributionPointContentPhase::Validate, + SccmDistributionPointContentPhase::MakeAvailable, + ]; + let mut selected = Vec::new(); + let mut previous_timestamp = None; + let mut last_proven_phase = None; + let mut recovered = false; + let mut outcome = None; + + for phase in required_phases + .into_iter() + .chain([SccmDistributionPointContentPhase::ServeOrReport]) + { + let phase_facts = envelope + .facts + .iter() + .filter(|fact| fact.phase == phase) + .filter(|fact| { + fact.timestamp.utc_millis.is_some_and(|timestamp| { + previous_timestamp.is_none_or(|previous| timestamp > previous) + }) + }) + .cloned() + .collect::>(); + + if phase_facts.is_empty() { + let decisive = + decisive_missing_phase_fact(&envelope.facts, phase, previous_timestamp).cloned(); + if phase == SccmDistributionPointContentPhase::ServeOrReport { + outcome = Some(if let Some(fact) = decisive { + selected.push(fact); + SccmDistributionPointContentState::Contradictory + } else { + SccmDistributionPointContentState::Succeeded + }); + } else { + outcome = Some(if let Some(fact) = decisive { + selected.push(fact); + SccmDistributionPointContentState::Contradictory + } else { + SccmDistributionPointContentState::Incomplete + }); + } + break; + } + + let latest_timestamp = phase_facts + .iter() + .filter_map(|fact| fact.timestamp.utc_millis) + .max() + .expect("admitted DP facts carry normalized UTC"); + let latest_dispositions = phase_facts + .iter() + .filter(|fact| fact.timestamp.utc_millis == Some(latest_timestamp)) + .map(|fact| fact.disposition) + .collect::>(); + selected.extend(phase_facts.iter().cloned()); + + if latest_dispositions.len() != 1 { + outcome = Some(SccmDistributionPointContentState::Contradictory); + break; + } + let disposition = *latest_dispositions + .first() + .expect("latest DP phase has a disposition"); + if disposition == SccmDistributionPointContentDisposition::Succeeded { + recovered |= phase_facts.iter().any(|fact| { + fact.timestamp.utc_millis != Some(latest_timestamp) + && fact.disposition != SccmDistributionPointContentDisposition::Succeeded + }); + last_proven_phase = Some(phase); + previous_timestamp = Some(latest_timestamp); + if phase == SccmDistributionPointContentPhase::ServeOrReport { + outcome = Some(SccmDistributionPointContentState::Succeeded); + break; + } + continue; + } + + let decisive_downstream = + decisive_downstream_fact(&envelope.facts, phase, latest_timestamp).cloned(); + outcome = Some(if let Some(fact) = decisive_downstream { + selected.push(fact); + SccmDistributionPointContentState::Contradictory + } else { + match disposition { + SccmDistributionPointContentDisposition::Failed => { + SccmDistributionPointContentState::Failed + } + SccmDistributionPointContentDisposition::Retrying => { + SccmDistributionPointContentState::Retrying + } + SccmDistributionPointContentDisposition::Blocked => { + SccmDistributionPointContentState::Blocked + } + SccmDistributionPointContentDisposition::Deferred => { + SccmDistributionPointContentState::Deferred + } + SccmDistributionPointContentDisposition::Succeeded => unreachable!(), + } + }); + break; + } + + let state = outcome.unwrap_or(SccmDistributionPointContentState::Incomplete); + let stop_phase = if state == SccmDistributionPointContentState::Succeeded { + None + } else { + required_phases + .into_iter() + .find(|phase| Some(*phase) > last_proven_phase) + .or_else(|| { + envelope + .facts + .iter() + .any(|fact| fact.phase == SccmDistributionPointContentPhase::ServeOrReport) + .then_some(SccmDistributionPointContentPhase::ServeOrReport) + }) + }; + + let key = SccmDistributionPointContentKey { + package_id: envelope.key.package_id, + content_id: envelope.key.content_id, + content_version: envelope.key.content_version, + topology_site_handle: envelope.topology_site_handle, + site_code: envelope.key.site_code, + distribution_point_handle: envelope.key.distribution_point_handle, + extraction_profile_id: SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_ID.to_owned(), + extraction_profile_version: SCCM_DISTRIBUTION_POINT_CONTENT_PROFILE_VERSION, + }; + let transaction_id = distribution_point_transaction_id(&key); + let evidence = selected + .iter() + .map(|fact| fact.reference.clone()) + .collect::>(); + let terminal_evidence = selected + .iter() + .filter(|fact| { + fact.terminal && fact.disposition == SccmDistributionPointContentDisposition::Failed + }) + .map(|fact| fact.reference.clone()) + .collect::>(); + let observations = selected + .into_iter() + .map(|fact| SccmDistributionPointContentObservation { + phase: fact.phase, + disposition: fact.disposition, + terminal: fact.terminal, + source_id: fact.source_id, + timestamp: fact.timestamp, + evidence: fact.reference, + }) + .collect::>(); + let (classification, confidence, severity) = content_outcome_contract(state, last_proven_phase); + let next_artifact = if matches!( + state, + SccmDistributionPointContentState::Failed | SccmDistributionPointContentState::Succeeded + ) { + None + } else { + stop_phase.map(artifact_request_for_phase) + }; + + SccmDistributionPointContentTransaction { + transaction_id, + key, + state, + classification, + confidence, + severity, + scope: SccmDistributionPointContentScope::DistributionPointContent, + last_proven_phase, + stop_phase, + recovered, + content_version_mismatch: false, + evidence, + terminal_evidence, + next_artifact, + observations, + } +} + +fn content_outcome_contract( + state: SccmDistributionPointContentState, + last_proven_phase: Option, +) -> ( + SccmDistributionPointContentClassification, + SccmDistributionPointContentConfidence, + Severity, +) { + match state { + SccmDistributionPointContentState::Succeeded => ( + SccmDistributionPointContentClassification::Success, + if last_proven_phase == Some(SccmDistributionPointContentPhase::ServeOrReport) { + SccmDistributionPointContentConfidence::High + } else { + SccmDistributionPointContentConfidence::Medium + }, + Severity::Success, + ), + SccmDistributionPointContentState::Failed => ( + SccmDistributionPointContentClassification::ConfirmedFailure, + SccmDistributionPointContentConfidence::High, + Severity::Error, + ), + SccmDistributionPointContentState::Retrying + | SccmDistributionPointContentState::Blocked + | SccmDistributionPointContentState::Deferred => ( + SccmDistributionPointContentClassification::BlockedOrDeferred, + SccmDistributionPointContentConfidence::Medium, + Severity::Warning, + ), + SccmDistributionPointContentState::Contradictory => ( + SccmDistributionPointContentClassification::ContradictoryEvidence, + SccmDistributionPointContentConfidence::Low, + Severity::Error, + ), + SccmDistributionPointContentState::Incomplete => ( + SccmDistributionPointContentClassification::InsufficientEvidence, + SccmDistributionPointContentConfidence::Low, + Severity::Warning, + ), + } +} + +fn artifact_request_for_phase(phase: SccmDistributionPointContentPhase) -> SccmArtifactRequest { + match phase { + SccmDistributionPointContentPhase::ReceiveContent + | SccmDistributionPointContentPhase::Distribute => SccmArtifactRequest { + logical_id: "distmgr".to_owned(), + role: SccmRole::SiteServer, + reason: "Collect the complete distmgr.log file.".to_owned(), + }, + SccmDistributionPointContentPhase::Transfer => SccmArtifactRequest { + logical_id: "pkgXferMgr".to_owned(), + role: SccmRole::SiteServer, + reason: "Collect the complete PkgXferMgr.log file.".to_owned(), + }, + SccmDistributionPointContentPhase::Validate + | SccmDistributionPointContentPhase::MakeAvailable => SccmArtifactRequest { + logical_id: "smsDpProv".to_owned(), + role: SccmRole::DistributionPoint, + reason: "Collect the complete SMSDPProv.log file.".to_owned(), + }, + SccmDistributionPointContentPhase::ServeOrReport => SccmArtifactRequest { + logical_id: "smsDpmon".to_owned(), + role: SccmRole::DistributionPoint, + reason: "Collect the complete SMSdpmon.log file.".to_owned(), + }, + } +} + +fn distribution_point_transaction_id(key: &SccmDistributionPointContentKey) -> String { + format!( + "dp:topology-site={}:site={}:package={}:content={}:content-version={}:dp={}:profile={}:profile-version={}", + key.topology_site_handle, + key.site_code, + key.package_id, + key.content_id, + key.content_version, + key.distribution_point_handle, + key.extraction_profile_id, + key.extraction_profile_version, + ) +} + +fn exact_message_token<'a>(message: &'a str, label: &str) -> Option<&'a str> { + let prefix = format!("{label}="); + let mut values = message + .split(';') + .map(str::trim) + .filter_map(|segment| segment.strip_prefix(&prefix)); + let value = values.next()?; + values.next().is_none().then_some(value) +} + +fn content_phase(value: &str) -> Option { + match value { + "receiveContent" => Some(SccmDistributionPointContentPhase::ReceiveContent), + "distribute" => Some(SccmDistributionPointContentPhase::Distribute), + "transfer" => Some(SccmDistributionPointContentPhase::Transfer), + "validate" => Some(SccmDistributionPointContentPhase::Validate), + "makeAvailable" => Some(SccmDistributionPointContentPhase::MakeAvailable), + "serveOrReport" => Some(SccmDistributionPointContentPhase::ServeOrReport), + _ => None, + } +} + +fn content_disposition(value: &str) -> Option { + match value { + "succeeded" => Some(SccmDistributionPointContentDisposition::Succeeded), + "failed" => Some(SccmDistributionPointContentDisposition::Failed), + "retrying" => Some(SccmDistributionPointContentDisposition::Retrying), + "blocked" => Some(SccmDistributionPointContentDisposition::Blocked), + "deferred" => Some(SccmDistributionPointContentDisposition::Deferred), + _ => None, + } +} + +fn safe_package_id(value: &str) -> bool { + (3..=32).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) +} + +fn safe_content_id(value: &str) -> bool { + (3..=128).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn safe_site_code(value: &str) -> bool { + value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +fn safe_distribution_point_handle(value: &str) -> bool { + matches!(value, "synthetic:subject:dp-01" | "synthetic:subject:dp-02") +} + +fn intake_authority_invalid_analysis() -> SccmDistributionPointAnalysis { + let coverage_gaps = vec![SccmDistributionPointCoverageGap { + source_id: SCCM_DISTRIBUTION_POINT_SOURCE_ID.to_owned(), + producer_role: None, + workflow_subject_role: Some(SccmRole::DistributionPoint), + state: Some(SccmCoverageState::ParseFailed), + artifact_ids: Vec::new(), + reason: SCCM_DISTRIBUTION_POINT_INTAKE_AUTHORITY_REASON.to_owned(), + }]; + let artifact_requests = artifact_requests(&coverage_gaps); + + SccmDistributionPointAnalysis { + schema_version: SCCM_DISTRIBUTION_POINT_ANALYSIS_SCHEMA_VERSION, + workflow: SccmDistributionPointWorkflow::DistributionPointContent, + profile: SccmDistributionPointProfile { + id: SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_ID.to_owned(), + version: SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_VERSION, + stability: "experimental".to_owned(), + }, + source_observations: Vec::new(), + coverage_gaps, + artifact_requests, + cross_side_correlation_performed: false, + } +} + +fn is_dp_distribution_artifact(artifact: &SccmServerArtifactAssessment) -> bool { + let Some(basename) = artifact.original_basename.as_deref() else { + return false; + }; + let classified = classify_artifact_name(basename, artifact.producer_role.clone()); + + matches!( + artifact.source_id.as_str(), + SCCM_DISTRIBUTION_POINT_SOURCE_ID | "server-dp-serve" + ) && artifact.source_kind == "ccmLog" + && artifact.family == SccmArtifactFamily::DistributionPoint + && classified.supported_for_diagnosis + && declared_server_source_catalog().iter().any(|spec| { + spec.source_id == artifact.source_id + && spec.producer_role == artifact.producer_role + && spec.workflow_subject_role.as_ref() == artifact.workflow_subject_role.as_ref() + && spec.source_kind == SccmServerSourceKind::CcmLog + && spec + .logical_names + .iter() + .any(|logical_name| *logical_name == classified.logical_name) + }) +} + +fn admitted_for_source_observation( + artifact: &SccmServerArtifactAssessment, + evidence: &SccmEvidence, +) -> bool { + artifact.state == SccmCoverageState::Captured + && artifact.profile_eligible + && artifact.parser_eligible + && artifact.fragment_complete != Some(false) + && evidence.role == artifact.producer_role + && evidence.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc +} + +fn artifact_metadata_is_congruent( + intake: &SccmServerIntakeAssessment, + artifact: &SccmServerArtifactAssessment, + artifact_id_counts: &BTreeMap<&str, usize>, +) -> bool { + artifact.state == SccmCoverageState::Captured + && artifact.profile_eligible + && artifact.parser_eligible + && artifact.fragment_complete != Some(false) + && rotation_is_canonical_for_artifact(artifact) + && safe_assessed_handle(&intake.topology.capture_host_handle) + && safe_assessed_handle(&intake.topology.site_handle) + && artifact + .producer_host_handle + .as_deref() + .is_some_and(safe_assessed_handle) + && subject_handle_is_congruent(artifact) + && topology_is_congruent(intake, artifact) + && coverage_is_congruent(intake, artifact, artifact_id_counts) +} + +fn rotation_is_canonical_for_artifact(artifact: &SccmServerArtifactAssessment) -> bool { + let Some(basename) = artifact.original_basename.as_deref() else { + return false; + }; + let classified = classify_artifact_name(basename, artifact.producer_role.clone()); + artifact.rotation.as_ref() == Some(&classified.rotation) + && safe_assessed_handle(&artifact.rotation_lineage_handle) +} + +fn safe_assessed_handle(value: &str) -> bool { + !value.is_empty() + && value.trim() == value + && !value.chars().any(char::is_control) + && value.len() <= 256 +} + +fn subject_handle_is_congruent(artifact: &SccmServerArtifactAssessment) -> bool { + match ( + &artifact.producer_role, + artifact.workflow_subject_role.as_ref(), + artifact.workflow_subject_handle.as_deref(), + ) { + (SccmRole::SiteServer, Some(SccmRole::DistributionPoint), Some(handle)) => { + safe_assessed_handle(handle) + } + (SccmRole::DistributionPoint, None, None) => true, + _ => false, + } +} + +fn topology_is_congruent( + intake: &SccmServerIntakeAssessment, + artifact: &SccmServerArtifactAssessment, +) -> bool { + role_occurrences(&intake.topology.roles_observed, &artifact.producer_role) == 1 + && artifact + .workflow_subject_role + .as_ref() + .is_none_or(|role| role_occurrences(&intake.topology.roles_observed, role) == 1) +} + +fn role_occurrences(roles: &[SccmRole], expected: &SccmRole) -> usize { + roles.iter().filter(|role| *role == expected).count() +} + +fn coverage_is_congruent( + intake: &SccmServerIntakeAssessment, + artifact: &SccmServerArtifactAssessment, + artifact_id_counts: &BTreeMap<&str, usize>, +) -> bool { + let memberships = intake + .coverage + .iter() + .filter(|coverage| { + coverage + .artifact_ids + .iter() + .any(|artifact_id| artifact_id == &artifact.artifact_id) + }) + .collect::>(); + let Some(coverage) = memberships.first() else { + return false; + }; + memberships.len() == 1 + && coverage + .artifact_ids + .iter() + .filter(|artifact_id| *artifact_id == &artifact.artifact_id) + .count() + == 1 + && coverage.producer_role == artifact.producer_role + && coverage.producer_host_handle == artifact.producer_host_handle + && coverage.workflow_subject_role == artifact.workflow_subject_role + && coverage.workflow_subject_handle == artifact.workflow_subject_handle + && coverage.source_id == artifact.source_id + && coverage.state == artifact.state + && coverage.artifact_ids.iter().all(|artifact_id| { + artifact_id_counts + .get(artifact_id.as_str()) + .is_some_and(|count| *count == 1) + && intake.artifacts.iter().any(|candidate| { + candidate.artifact_id == *artifact_id + && candidate.producer_role == coverage.producer_role + && candidate.producer_host_handle == coverage.producer_host_handle + && candidate.workflow_subject_role == coverage.workflow_subject_role + && candidate.workflow_subject_handle == coverage.workflow_subject_handle + && candidate.source_id == coverage.source_id + && candidate.state == coverage.state + && is_dp_distribution_artifact(candidate) + && rotation_is_canonical_for_artifact(candidate) + }) + }) +} + +fn canonical_evidence_set( + artifact: &SccmServerArtifactAssessment, + evidence: &[&SccmEvidence], +) -> bool { + if evidence.is_empty() { + return false; + } + + let mut ranges = Vec::with_capacity(evidence.len()); + let mut evidence_ids = BTreeSet::new(); + let mut entry_ids = BTreeSet::new(); + for item in evidence { + let (Some(line_start), Some(line_end)) = + (item.reference.line_start, item.reference.line_end) + else { + return false; + }; + let expected_entry_id = format!("{}:{line_start}-{line_end}", artifact.artifact_id); + if line_start == 0 + || line_end < line_start + || item.reference.artifact_id != artifact.artifact_id + || item.reference.entry_id != expected_entry_id + || item.evidence_id != item.reference.entry_id + || !evidence_ids.insert(item.evidence_id.as_str()) + || !entry_ids.insert(item.reference.entry_id.as_str()) + || item.role != artifact.producer_role + || item.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + || item.timestamp.offset_minutes.is_none() + || item.timestamp.utc_millis.is_none() + { + return false; + } + ranges.push((line_start, line_end)); + } + + ranges.sort_unstable(); + ranges + .windows(2) + .all(|pair| pair[0].1.checked_add(1) == Some(pair[1].0)) +} + +fn coverage_gaps( + intake: &SccmServerIntakeAssessment, + observations: &[SccmDistributionPointSourceObservation], +) -> Vec { + let observed_artifact_ids = observations + .iter() + .map(|observation| observation.artifact_id.as_str()) + .collect::>(); + let mut gaps = intake + .coverage + .iter() + .filter(|coverage| is_dp_distribution_coverage(coverage)) + .filter_map(|coverage| { + let mut artifact_ids = coverage.artifact_ids.clone(); + artifact_ids.sort(); + let all_admitted = coverage.state == SccmCoverageState::Captured + && !artifact_ids.is_empty() + && artifact_ids + .iter() + .all(|artifact_id| observed_artifact_ids.contains(artifact_id.as_str())); + (!all_admitted).then(|| SccmDistributionPointCoverageGap { + source_id: coverage.source_id.clone(), + producer_role: Some(coverage.producer_role.clone()), + workflow_subject_role: coverage.workflow_subject_role.clone(), + state: Some(coverage.state.clone()), + artifact_ids, + reason: coverage_gap_reason(coverage, &observed_artifact_ids), + }) + }) + .collect::>(); + + if !intake.coverage.iter().any(is_dp_distribution_coverage) { + gaps.push(SccmDistributionPointCoverageGap { + source_id: SCCM_DISTRIBUTION_POINT_SOURCE_ID.to_owned(), + producer_role: None, + workflow_subject_role: Some(SccmRole::DistributionPoint), + state: None, + artifact_ids: Vec::new(), + reason: "No declared Distribution Point distribution source was supplied.".to_owned(), + }); + } + + gaps +} + +fn is_dp_distribution_coverage(coverage: &SccmServerCoverage) -> bool { + coverage.source_id == SCCM_DISTRIBUTION_POINT_SOURCE_ID + && matches!( + ( + &coverage.producer_role, + coverage.workflow_subject_role.as_ref() + ), + (SccmRole::SiteServer, Some(SccmRole::DistributionPoint)) + | (SccmRole::DistributionPoint, None) + ) +} + +fn coverage_gap_reason( + coverage: &SccmServerCoverage, + observed_artifact_ids: &BTreeSet<&str>, +) -> String { + if coverage.state != SccmCoverageState::Captured { + return format!( + "Distribution Point source coverage is {}; recollect the declared source without changing its state.", + coverage_state_label(&coverage.state) + ); + } + if coverage + .artifact_ids + .iter() + .any(|artifact_id| !observed_artifact_ids.contains(artifact_id.as_str())) + { + return "Captured Distribution Point evidence is incomplete or outside the supported intake profile." + .to_owned(); + } + "Distribution Point coverage requires a complete supported source.".to_owned() +} + +fn artifact_requests(gaps: &[SccmDistributionPointCoverageGap]) -> Vec { + let mut requests = Vec::with_capacity(gaps.len()); + for gap in gaps { + if gap.producer_role.is_none() { + // An unscoped coverage gap does not identify a failed source. Ask + // for the bounded sources required by the healthy DP profile. + requests.push(SccmArtifactRequest { + logical_id: "distmgr".to_owned(), + role: SccmRole::SiteServer, + reason: "Collect the complete distmgr.log file.".to_owned(), + }); + requests.push(SccmArtifactRequest { + logical_id: "pkgXferMgr".to_owned(), + role: SccmRole::SiteServer, + reason: "Collect the complete PkgXferMgr.log file.".to_owned(), + }); + requests.push(SccmArtifactRequest { + logical_id: "smsDpProv".to_owned(), + role: SccmRole::DistributionPoint, + reason: "Collect the complete SMSDPProv.log file.".to_owned(), + }); + } else if gap.producer_role == Some(SccmRole::DistributionPoint) { + requests.push(SccmArtifactRequest { + logical_id: "smsDpProv".to_owned(), + role: SccmRole::DistributionPoint, + reason: "Collect the complete SMSDPProv.log file.".to_owned(), + }); + } else { + requests.push(SccmArtifactRequest { + logical_id: "distmgr".to_owned(), + role: SccmRole::SiteServer, + reason: "Collect the complete distmgr.log file.".to_owned(), + }); + requests.push(SccmArtifactRequest { + logical_id: "pkgXferMgr".to_owned(), + role: SccmRole::SiteServer, + reason: "Collect the complete PkgXferMgr.log file.".to_owned(), + }); + } + } + requests.sort_by(|left, right| { + ( + left.logical_id.as_str(), + role_sort_key(&left.role), + left.reason.as_str(), + ) + .cmp(&( + right.logical_id.as_str(), + role_sort_key(&right.role), + right.reason.as_str(), + )) + }); + requests.dedup_by(|left, right| { + left.logical_id == right.logical_id + && left.role == right.role + && left.reason == right.reason + }); + requests +} + +fn source_observation_sort_key( + observation: &SccmDistributionPointSourceObservation, +) -> (String, u32, u32, String) { + ( + observation.artifact_id.clone(), + observation.evidence.line_start.unwrap_or_default(), + observation.evidence.line_end.unwrap_or_default(), + observation.evidence.entry_id.clone(), + ) +} + +fn coverage_gap_sort_key( + gap: &SccmDistributionPointCoverageGap, +) -> (String, String, String, String, Vec) { + ( + gap.source_id.clone(), + gap.producer_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + gap.workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + gap.state + .as_ref() + .map(coverage_state_label) + .unwrap_or_default() + .to_owned(), + gap.artifact_ids.clone(), + ) +} + +fn coverage_state_label(state: &SccmCoverageState) -> &'static str { + match state { + SccmCoverageState::Captured => "captured", + SccmCoverageState::Absent => "absent", + SccmCoverageState::AccessDenied => "accessDenied", + SccmCoverageState::Capped => "capped", + SccmCoverageState::Skipped => "skipped", + SccmCoverageState::Unsupported => "unsupported", + SccmCoverageState::ParseFailed => "parseFailed", + } +} + +fn role_sort_key(role: &SccmRole) -> &str { + match role { + SccmRole::Client => "client", + SccmRole::SiteServer => "siteServer", + SccmRole::ManagementPoint => "managementPoint", + SccmRole::DistributionPoint => "distributionPoint", + SccmRole::SoftwareUpdatePoint => "softwareUpdatePoint", + SccmRole::WsUs => "wsUs", + SccmRole::Provider => "provider", + SccmRole::AdminService => "adminService", + SccmRole::Unknown(value) => value, + } +} + +#[cfg(test)] +mod artifact_request_tests { + use super::*; + + fn gap(producer_role: Option) -> SccmDistributionPointCoverageGap { + SccmDistributionPointCoverageGap { + source_id: SCCM_DISTRIBUTION_POINT_SOURCE_ID.to_owned(), + producer_role, + workflow_subject_role: Some(SccmRole::DistributionPoint), + state: Some(SccmCoverageState::Absent), + artifact_ids: Vec::new(), + reason: "controlled coverage gap".to_owned(), + } + } + + fn contracts(requests: &[SccmArtifactRequest]) -> Vec<(&str, SccmRole, &str)> { + requests + .iter() + .map(|request| { + ( + request.logical_id.as_str(), + request.role.clone(), + request.reason.as_str(), + ) + }) + .collect() + } + + fn expected_site_requests() -> Vec<(&'static str, SccmRole, &'static str)> { + vec![ + ( + "distmgr", + SccmRole::SiteServer, + "Collect the complete distmgr.log file.", + ), + ( + "pkgXferMgr", + SccmRole::SiteServer, + "Collect the complete PkgXferMgr.log file.", + ), + ] + } + + fn expected_all_requests() -> Vec<(&'static str, SccmRole, &'static str)> { + let mut requests = expected_site_requests(); + requests.push(( + "smsDpProv", + SccmRole::DistributionPoint, + "Collect the complete SMSDPProv.log file.", + )); + requests + } + + fn expected_dp_requests() -> Vec<(&'static str, SccmRole, &'static str)> { + vec![( + "smsDpProv", + SccmRole::DistributionPoint, + "Collect the complete SMSDPProv.log file.", + )] + } + + #[test] + fn artifact_requests_cover_required_sources_by_gap_scope_deterministically() { + let site_gap = gap(Some(SccmRole::SiteServer)); + let site_requests = artifact_requests(&[site_gap.clone(), site_gap.clone()]); + assert_eq!(contracts(&site_requests), expected_site_requests()); + + let unscoped_gap = gap(None); + let unscoped_requests = artifact_requests(&[unscoped_gap.clone(), unscoped_gap.clone()]); + assert_eq!(contracts(&unscoped_requests), expected_all_requests()); + + let dp_gap = gap(Some(SccmRole::DistributionPoint)); + let dp_requests = artifact_requests(&[dp_gap.clone(), dp_gap.clone()]); + assert_eq!(contracts(&dp_requests), expected_dp_requests()); + + let forward = artifact_requests(&[site_gap.clone(), unscoped_gap.clone(), dp_gap.clone()]); + let reversed = artifact_requests(&[dp_gap, unscoped_gap, site_gap]); + assert_eq!(forward, reversed); + } +} + +#[cfg(test)] +mod content_identity_tests { + use super::*; + + fn key( + site_code: &str, + profile_id: &str, + profile_version: u32, + ) -> SccmDistributionPointContentKey { + SccmDistributionPointContentKey { + package_id: "LAB00001".to_owned(), + content_id: "content-alpha".to_owned(), + content_version: 1, + topology_site_handle: "synthetic:site:lab".to_owned(), + site_code: site_code.to_owned(), + distribution_point_handle: "safe:dp:lab-dp-01".to_owned(), + extraction_profile_id: profile_id.to_owned(), + extraction_profile_version: profile_version, + } + } + + #[test] + fn transaction_identity_includes_site_topology_and_profile_version_deterministically() { + let lab = key("LAB", "dp-server-5.00.test-v1", 1); + let abc = key("ABC", "dp-server-5.00.test-v1", 1); + let profile_v2 = key("LAB", "dp-server-5.00.test-v2", 2); + + assert_eq!( + distribution_point_transaction_id(&lab), + "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00001:content=content-alpha:content-version=1:dp=safe:dp:lab-dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + ); + assert_ne!( + distribution_point_transaction_id(&lab), + distribution_point_transaction_id(&abc) + ); + assert_ne!( + distribution_point_transaction_id(&lab), + distribution_point_transaction_id(&profile_v2) + ); + + let mut forward = [&lab, &abc, &profile_v2] + .into_iter() + .map(distribution_point_transaction_id) + .collect::>(); + let mut reversed = [&profile_v2, &abc, &lab] + .into_iter() + .map(distribution_point_transaction_id) + .collect::>(); + forward.sort(); + reversed.sort(); + assert_eq!(forward, reversed); + } + + #[test] + fn transaction_identity_distinguishes_canonical_topology_with_identical_message_keys() { + let lab = SccmDistributionPointContentKey { + package_id: "LAB00001".to_owned(), + content_id: "content-alpha".to_owned(), + content_version: 1, + topology_site_handle: "synthetic:site:lab".to_owned(), + site_code: "LAB".to_owned(), + distribution_point_handle: "safe:dp:lab-dp-01".to_owned(), + extraction_profile_id: "dp-server-5.00.test-v1".to_owned(), + extraction_profile_version: 1, + }; + let peer = SccmDistributionPointContentKey { + topology_site_handle: "synthetic:site:lab-peer".to_owned(), + ..lab.clone() + }; + + let lab_id = distribution_point_transaction_id(&lab); + let peer_id = distribution_point_transaction_id(&peer); + assert_ne!(lab, peer); + assert_ne!(lab_id, peer_id); + + let mut forward = [lab_id.as_str(), peer_id.as_str()]; + let mut reversed = [peer_id.as_str(), lab_id.as_str()]; + forward.sort(); + reversed.sort(); + assert_eq!(forward, reversed); + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs new file mode 100644 index 000000000..e20c1e0b4 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs @@ -0,0 +1,1645 @@ +//! Evidence-bound SCCM hierarchy and replication analysis. +//! +//! The extractor consumes already framed CCM logical records. The committed +//! corpus is synthetic and selects one closed test profile; this module makes +//! no native Windows or live ConfigMgr validation claim. + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::models::log_entry::Severity; +use crate::sccm::keys::SCCM_HIERARCHY_KEY_PROFILE_ID; +use crate::sccm::{ + extract_keys, SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, SccmCorrelationKey, + SccmCorrelationKeyKind, SccmCoverageState, SccmEvidenceRef, SccmExtractionProfile, SccmFinding, + SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, SccmPhase, SccmRole, + SccmRotation, SccmTerminalEvidence, SccmTimeOrderingState, SccmTimestamp, +}; + +use super::intake::{ + SccmServerArtifactAssessment, SccmServerHierarchyLinkTopology, SccmServerIntakeAssessment, +}; + +const PUBLIC_MESSAGE_PREFIX: &str = "[sccm-public-message-v1] "; +const FIXTURE_MARKER: &str = "SYNTHETIC FIXTURE"; +pub const SCCM_HIERARCHY_PROFILE_ID: &str = SCCM_HIERARCHY_KEY_PROFILE_ID; +pub const SCCM_HIERARCHY_SOURCE_VERSION: &str = "5.00.TEST"; +pub const SCCM_HIERARCHY_PROFILE_VERSION: u32 = 1; +const MISSING_TARGET_REASON_CODE: &str = "missingTargetReceiveProcessApply"; + +// Exact raw-payload registry for the synthetic profile. A caller can invoke +// canonical intake, but cannot turn arbitrary CCM text into reviewed hierarchy +// facts by reusing one of the public fixture artifact IDs. +const SYNTHETIC_PROFILE_PAYLOADS: &[(&str, &str)] = &[ + ( + "absent-01-sender", + "e2b8000d9c61a1d8cc8fc5adf67aa09ef18c0ac83aa6c2be82bb31ab96cf7d43", + ), + ( + "backlog-01-replmgr", + "5076cad377b0161380e205b6da68743f77d9cca3b1ce38be518283f7a9b6b4a3", + ), + ( + "clock-01-sender", + "0d0f5f0e21da23617b45afeb469cd315bd250c19d61e98ceda4d8982d9b1e8c7", + ), + ( + "clock-02-despool", + "e6f19320c96b7939a25124bb8bfaa3699e9762c08e6a2f7c86d0290cd98c24a1", + ), + ( + "generic-01-sender", + "1e16a6a63b19610c91ea714fa74d82328b5d1e64f5d680d43842312c19722530", + ), + ( + "healthy-01-replmgr", + "46c5657073e106c6543283d2374dd4c16a4018288a27b2fca4f5581bd7dd0a94", + ), + ( + "healthy-01-replmgr", + "8dfae54db00614bd99b39b77dc7d6fed838be518bc2e7c44045057382303734d", + ), + ( + "healthy-01-replmgr", + "03b983e6f7282d3c919e46cc44e721521c41f0b32bf7ffd5559d9c95374f954a", + ), + ( + "healthy-02-sender", + "7147bd84db5386ecb685519a9393b26ddd1280ce179ff3bf5acac8c05f9e004a", + ), + ( + "healthy-02-sender", + "60755b460ccc3bd4c77ad30c8bf8b3e0c72091c7e9e05da8061e79c4e1799687", + ), + ( + "healthy-03-despool", + "6fecb3909732f086f2cd3ec0244a345373f6754e5e53e948a20c414ece8a6d49", + ), + ( + "healthy-04-rcmctrl", + "12b3a0d0210606312c744e38d34f54c4f4210b8e1def4acab85fbaf05cb023c7", + ), + ( + "incomplete-01-replmgr", + "179bf0e10615d0ffa0a5ec72748e79d6b8ec86e26d285d75b33592c91fbde25e", + ), + ( + "mismatch-01-sender", + "6f0813d7bf0309401adaac19ac96fe4b674ab8491d3dc5f581f529c9fa108dcd", + ), + ( + "mismatch-02-despool", + "8d08a41bc7d735ecbaae4c8dc18285f1a77b65e30fb3903fa3e83b4d7be40a8b", + ), + ( + "receiver-01-sender", + "efa57e6e8b95133d4fa1be54998eec8959e4c85ad0a28221adff44d93e48ff9f", + ), + ( + "receiver-02-despool", + "34ae490e3409e3f3f3c63257a6d72c60e73e05383b8dbdec7747856ae514bc83", + ), + ( + "recovery-01-sender", + "9ae1d3697976dfe61cf7aa3638086d91602b7f28ed41f1af4a6edc0e31b2652b", + ), + ( + "recovery-02-despool", + "c391334f761c4acb88c0dfb21edaa870ba9d8da51d315dfbc948e337fb71b2a2", + ), + ( + "rotation-01-current", + "b678f0249923f82c2fdbd3e532209c45aa81921bdbf542ba655056bb5f102705", + ), + ( + "rotation-02-lo", + "550fdcc7fae3b2f9f8586676cd964e21f265e0bc766d26c455bf816ba52221ab", + ), + ( + "sender-failure-01-chd", + "52f2b9609a19a966680e063994d2656fd93a89929e5d6a6f46978c9cfd0ddb08", + ), + ( + "sender-failure-01-chd", + "2b47b825171f2acf95895adb206f79ec65afb4c1126a535866127a1b907ba990", + ), +]; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmHierarchyDirection { + Origin, + Target, + Both, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyCoverage { + pub artifact_id: String, + pub source_id: String, + pub producer_role: SccmRole, + pub producer_host_handle: String, + pub state: SccmCoverageState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmHierarchyPhase { + Initiate, + QueueOrSerialize, + Send, + Receive, + Process, + Acknowledge, + HealthyOrTerminal, +} + +impl SccmHierarchyPhase { + fn parse(value: &str) -> Option { + Some(match value { + "initiate" => Self::Initiate, + "queueOrSerialize" => Self::QueueOrSerialize, + "send" => Self::Send, + "receive" => Self::Receive, + "process" => Self::Process, + "acknowledge" => Self::Acknowledge, + "healthyOrTerminal" => Self::HealthyOrTerminal, + _ => return None, + }) + } + + fn rank(&self) -> usize { + match self { + Self::Initiate => 0, + Self::QueueOrSerialize => 1, + Self::Send => 2, + Self::Receive => 3, + Self::Process => 4, + Self::Acknowledge => 5, + Self::HealthyOrTerminal => 6, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmHierarchyDisposition { + Succeeded, + Failed, + Retrying, +} + +impl SccmHierarchyDisposition { + fn parse(value: &str) -> Option { + Some(match value { + "succeeded" => Self::Succeeded, + "failed" => Self::Failed, + "retrying" => Self::Retrying, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyObservation { + pub observation_id: String, + pub phase: SccmHierarchyPhase, + pub disposition: SccmHierarchyDisposition, + pub terminal: bool, + pub evidence: Vec, + #[serde(skip)] + pub timestamp: SccmTimestamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyKey { + pub message_id: String, + pub link_id: String, + pub origin_site_code: String, + pub target_site_code: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmHierarchyTopologyCompatibility { + Exact, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmHierarchyTimestampOrdering { + Usable, + UnusableInvalidOffset, + UnusableMissingOffset, + UnusableMissingTimestamp, + Contradictory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmHierarchyState { + Succeeded, + Failed, + Deferred, + Recovered, + Incomplete, + Contradictory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmHierarchyRemoteCausality { + EvidenceBound, + NotEstablished, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyArtifactRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction_id: Option, + pub source_id: String, + pub producer_role: SccmRole, + pub direction: SccmHierarchyDirection, + pub origin_site_code: String, + pub target_site_code: String, + pub basenames: Vec, + pub reason_code: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyTransaction { + pub transaction_id: String, + pub key: SccmHierarchyKey, + pub correlation_keys: Vec, + pub topology_compatibility: SccmHierarchyTopologyCompatibility, + pub timestamp_ordering: SccmHierarchyTimestampOrdering, + pub terminal_evidence: bool, + pub state: SccmHierarchyState, + pub finding_class: Option, + pub confidence: SccmConfidence, + pub confidence_ceiling: SccmConfidence, + pub producer_role: SccmRole, + pub source_version: String, + pub origin_host_handle: String, + pub target_host_handle: Option, + pub last_successful_phase: Option, + pub remote_causality: SccmHierarchyRemoteCausality, + pub correlation_eligible: bool, + pub coverage_gap_artifact_ids: Vec, + pub next_artifacts: Vec, + pub observations: Vec, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmHierarchyProfileSelectionState { + SelectedSynthetic, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyExtractionProfile { + pub selection_state: SccmHierarchyProfileSelectionState, + pub profile_id: Option, + pub profile_version: u32, + pub source_version: Option, + pub validated_role: Option, + pub synthetic_fixture_only: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchySourceLocalObservation { + pub observation_id: String, + pub finding_class: SccmFindingClass, + pub confidence: SccmConfidence, + pub correlation_eligible: bool, + pub artifact_ids: Vec, + pub evidence: Vec, + pub reason_code: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyAnalysis { + pub workflow: String, + pub state_chain: Vec, + pub extraction_profile: SccmHierarchyExtractionProfile, + pub transactions: Vec, + pub coverage: Vec, + pub source_local_observations: Vec, + pub artifact_requests: Vec, + pub findings: Vec, + pub cross_side_causal_claims: Vec, + pub native_validation_performed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SccmHierarchyError { + UntrustedIntake, + InvalidFindingContract, +} + +pub fn analyze_hierarchy_replication( + intake: &SccmServerIntakeAssessment, +) -> Result { + if !intake.adapter_authority_is_intake_bound() { + return Err(SccmHierarchyError::UntrustedIntake); + } + let mut artifacts = intake + .artifacts + .iter() + .filter(|artifact| artifact.family == SccmArtifactFamily::Hierarchy) + .collect::>(); + artifacts.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + let profile_selected = !artifacts.is_empty() + && !intake.topology.hierarchy_links.is_empty() + && artifacts.iter().all(|artifact| { + artifact.producer_role == SccmRole::SiteServer + && artifact.profile_eligible + && artifact.source_version.as_deref() == Some(SCCM_HIERARCHY_SOURCE_VERSION) + && (!matches!( + artifact.state, + SccmCoverageState::Captured + | SccmCoverageState::Capped + | SccmCoverageState::ParseFailed + ) || registered_profile_provenance(artifact)) + }); + let mut coverage = Vec::new(); + let mut source_local_observations = Vec::new(); + let mut grouped = BTreeMap::::new(); + + for artifact in &artifacts { + let state = artifact.state.clone(); + coverage.push(SccmHierarchyCoverage { + artifact_id: artifact.artifact_id.clone(), + source_id: artifact.source_id.clone(), + producer_role: artifact.producer_role.clone(), + producer_host_handle: artifact.producer_host_handle.clone().unwrap_or_default(), + state, + }); + + if !profile_selected + || artifact.state != SccmCoverageState::Captured + || !sealed_profile_artifact(artifact) + { + if artifact.state == SccmCoverageState::Captured || !profile_selected { + source_local_observations.push(source_local( + artifact, + "unvalidatedProfile", + Vec::new(), + )); + } else if matches!( + artifact.state, + SccmCoverageState::Capped | SccmCoverageState::ParseFailed + ) { + source_local_observations.push(source_local(artifact, "coverageOnly", Vec::new())); + } + continue; + } + + let profile = SccmExtractionProfile::for_artifact_family( + artifact.source_version.as_deref(), + &artifact.family, + ); + for evidence in intake + .evidence + .iter() + .filter(|evidence| evidence.reference.artifact_id == artifact.artifact_id) + { + let Some(parsed) = parse_public_record(&evidence.message) else { + if looks_like_hierarchy_record(&evidence.message) { + source_local_observations.push(source_local( + artifact, + "topologyOrGrammarMismatch", + vec![evidence.reference.clone()], + )); + } + continue; + }; + let extracted = extract_keys(evidence, &profile); + if !extracted.gaps.is_empty() + || !extracted_keys_match_record(&extracted.keys, &parsed) + || parsed.profile_id.as_deref().is_some_and(|profile_id| { + profile_id != "hierarchy-server-5.00.test-v1" + && profile_id != SCCM_HIERARCHY_PROFILE_ID + }) + || !exact_message_id(&parsed.message_id) + || !exact_link_id(&parsed.link_id) + || !exact_site_code(&parsed.origin_site) + || !exact_site_code(&parsed.target_site) + || !record_topology_matches(intake, artifact, &parsed) + || !phase_owned(artifact, &parsed.phase) + { + source_local_observations.push(source_local( + artifact, + "topologyOrGrammarMismatch", + vec![evidence.reference.clone()], + )); + continue; + } + + let key = SccmHierarchyKey { + message_id: parsed.message_id, + link_id: parsed.link_id, + origin_site_code: parsed.origin_site, + target_site_code: parsed.target_site, + }; + let group_key = transaction_id(&key); + let candidate = grouped.entry(group_key).or_insert_with(|| Candidate { + key, + correlation_keys: Vec::new(), + observations: Vec::new(), + evidence: Vec::new(), + directions: BTreeSet::new(), + }); + candidate + .directions + .insert(artifact_direction(artifact).expect("sealed hierarchy source")); + candidate.correlation_keys.extend(extracted.keys); + let reference = evidence.reference.clone(); + candidate.observations.push(SccmHierarchyObservation { + observation_id: observation_id( + &reference, + &parsed.phase, + &parsed.disposition, + parsed.terminal, + ), + phase: parsed.phase, + disposition: parsed.disposition, + terminal: parsed.terminal, + evidence: vec![reference.clone()], + timestamp: evidence.timestamp.clone(), + }); + candidate.evidence.push(reference); + } + } + + coverage.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + let (rotation_observations, rotation_requests) = rotation_split_outputs(intake, &artifacts); + let rotation_artifact_ids = rotation_observations + .iter() + .flat_map(|observation| observation.artifact_ids.iter()) + .collect::>(); + source_local_observations.retain(|observation| { + !observation + .artifact_ids + .iter() + .any(|artifact_id| rotation_artifact_ids.contains(artifact_id)) + }); + source_local_observations.extend(rotation_observations); + let mut artifact_requests = coverage_requests(intake, &artifacts); + artifact_requests.retain(|request| { + !rotation_requests.iter().any(|rotation| { + request.source_id == rotation.source_id + && request.direction == rotation.direction + && request.target_site_code == rotation.target_site_code + && request + .basenames + .iter() + .all(|basename| rotation.basenames.contains(basename)) + }) + }); + artifact_requests.extend(rotation_requests); + artifact_requests.sort_by(request_order); + artifact_requests.dedup_by(|left, right| request_order(left, right).is_eq()); + + let mut transactions = grouped + .into_values() + .filter_map(|mut candidate| { + candidate.observations.sort_by(observation_order); + candidate.evidence.sort_by(evidence_order); + candidate.evidence.dedup(); + candidate.correlation_keys.sort_by(correlation_key_order); + candidate.correlation_keys.dedup(); + let ordering = timestamp_ordering(&candidate.observations); + let contradictory = ordering == SccmHierarchyTimestampOrdering::Contradictory + || has_contradiction(&candidate.observations); + let terminal_failure = candidate.observations.iter().any(|observation| { + observation.terminal && observation.disposition == SccmHierarchyDisposition::Failed + }); + let retrying = candidate + .observations + .iter() + .any(|observation| observation.disposition == SccmHierarchyDisposition::Retrying); + let terminal_success = candidate.observations.iter().any(|observation| { + observation.terminal + && observation.disposition == SccmHierarchyDisposition::Succeeded + }); + let topology = topology_link(intake, &candidate.key)?; + let transaction_id = transaction_id(&candidate.key); + let missing_target_requests = + missing_target_source_requests(&artifacts, topology, &transaction_id); + let missing_target_gate = !missing_target_requests.is_empty(); + let gaps = missing_required_artifacts(intake, &artifacts, &candidate.key); + if candidate.observations.len() == 1 + && !terminal_failure + && !terminal_success + && !retrying + && gaps.is_empty() + && !missing_target_gate + { + let observation = &candidate.observations[0]; + source_local_observations.push(SccmHierarchySourceLocalObservation { + observation_id: format!("{}-unlinked", observation.observation_id), + finding_class: SccmFindingClass::InsufficientEvidence, + confidence: SccmConfidence::Low, + correlation_eligible: false, + artifact_ids: vec![observation_artifact_id(observation).to_owned()], + evidence: candidate.evidence, + reason_code: "unlinkedTopologyCandidate".to_owned(), + }); + return None; + } + let unusable_time = ordering != SccmHierarchyTimestampOrdering::Usable; + let (state, finding_class) = if missing_target_gate { + ( + SccmHierarchyState::Incomplete, + Some(SccmFindingClass::InsufficientEvidence), + ) + } else if contradictory { + ( + SccmHierarchyState::Contradictory, + Some(SccmFindingClass::Symptom), + ) + } else if unusable_time || !gaps.is_empty() { + ( + SccmHierarchyState::Incomplete, + Some(SccmFindingClass::InsufficientEvidence), + ) + } else if terminal_failure { + ( + SccmHierarchyState::Failed, + Some(SccmFindingClass::ConfirmedFailure), + ) + } else if terminal_success && retrying { + (SccmHierarchyState::Recovered, None) + } else if terminal_success { + (SccmHierarchyState::Succeeded, None) + } else if retrying { + ( + SccmHierarchyState::Deferred, + Some(SccmFindingClass::BlockedOrDeferred), + ) + } else { + ( + SccmHierarchyState::Incomplete, + Some(SccmFindingClass::InsufficientEvidence), + ) + }; + let confidence = match state { + SccmHierarchyState::Succeeded + | SccmHierarchyState::Failed + | SccmHierarchyState::Recovered => SccmConfidence::High, + SccmHierarchyState::Deferred => SccmConfidence::Moderate, + SccmHierarchyState::Incomplete | SccmHierarchyState::Contradictory => { + SccmConfidence::Low + } + }; + let last_successful_phase = candidate + .observations + .iter() + .filter(|observation| { + observation.disposition == SccmHierarchyDisposition::Succeeded + }) + .max_by_key(|observation| observation.phase.rank()) + .map(|observation| observation.phase.clone()); + let remote_causality = if candidate.directions.len() == 2 + && ordering == SccmHierarchyTimestampOrdering::Usable + && gaps.is_empty() + && missing_target_requests.is_empty() + && !contradictory + { + SccmHierarchyRemoteCausality::EvidenceBound + } else { + SccmHierarchyRemoteCausality::NotEstablished + }; + let mut next_artifacts = artifact_requests + .iter() + .filter(|request| { + request.origin_site_code == candidate.key.origin_site_code + && request.target_site_code == candidate.key.target_site_code + }) + .map(|request| { + let mut request = request.clone(); + request.transaction_id = Some(transaction_id.clone()); + request + }) + .collect::>(); + next_artifacts.retain(|request| { + !missing_target_requests.iter().any(|missing| { + request.source_id == missing.source_id + && request.direction == missing.direction + && request.origin_site_code == missing.origin_site_code + && request.target_site_code == missing.target_site_code + && request.basenames == missing.basenames + }) + }); + next_artifacts.extend(missing_target_requests); + next_artifacts.sort_by(request_order); + next_artifacts.dedup_by(|left, right| request_order(left, right).is_eq()); + let target_host_handle = Some(topology.target_host_handle.clone()); + let correlation_eligible = matches!( + state, + SccmHierarchyState::Succeeded + | SccmHierarchyState::Failed + | SccmHierarchyState::Recovered + ) && remote_causality + == SccmHierarchyRemoteCausality::EvidenceBound; + Some(SccmHierarchyTransaction { + transaction_id, + key: candidate.key, + correlation_keys: candidate.correlation_keys, + topology_compatibility: SccmHierarchyTopologyCompatibility::Exact, + timestamp_ordering: ordering, + terminal_evidence: candidate + .observations + .iter() + .any(|observation| observation.terminal), + state, + finding_class, + confidence, + confidence_ceiling: confidence, + producer_role: SccmRole::SiteServer, + source_version: SCCM_HIERARCHY_SOURCE_VERSION.to_owned(), + origin_host_handle: topology.origin_host_handle.clone(), + target_host_handle, + last_successful_phase, + remote_causality, + correlation_eligible, + coverage_gap_artifact_ids: gaps, + next_artifacts, + observations: candidate.observations, + evidence: candidate.evidence, + }) + }) + .collect::>(); + transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_id)); + for transaction in &mut transactions { + if transaction.timestamp_ordering == SccmHierarchyTimestampOrdering::Usable + || transaction + .next_artifacts + .iter() + .any(|request| request.reason_code == MISSING_TARGET_REASON_CODE) + { + continue; + } + let mut requests = invalid_time_requests(intake, transaction); + transaction.next_artifacts.extend(requests.iter().cloned()); + transaction.next_artifacts.sort_by(request_order); + transaction + .next_artifacts + .dedup_by(|left, right| request_order(left, right).is_eq()); + artifact_requests.append(&mut requests); + } + let transaction_scopes = transactions + .iter() + .map(|transaction| { + ( + transaction.key.origin_site_code.clone(), + transaction.key.target_site_code.clone(), + ) + }) + .collect::>(); + artifact_requests.retain(|request| { + request.transaction_id.is_some() + || !transaction_scopes.contains(&( + request.origin_site_code.clone(), + request.target_site_code.clone(), + )) + }); + artifact_requests.extend( + transactions + .iter() + .flat_map(|transaction| transaction.next_artifacts.iter().cloned()), + ); + artifact_requests.sort_by(request_order); + artifact_requests.dedup_by(|left, right| request_order(left, right).is_eq()); + source_local_observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + let findings = transactions + .iter() + .map(|transaction| build_finding(transaction, &coverage)) + .collect::, _>>()? + .into_iter() + .flatten() + .collect(); + + Ok(SccmHierarchyAnalysis { + workflow: "hierarchyAndReplication".to_owned(), + state_chain: state_chain(), + extraction_profile: SccmHierarchyExtractionProfile { + selection_state: if profile_selected { + SccmHierarchyProfileSelectionState::SelectedSynthetic + } else { + SccmHierarchyProfileSelectionState::Unavailable + }, + profile_id: profile_selected.then(|| SCCM_HIERARCHY_PROFILE_ID.to_owned()), + profile_version: SCCM_HIERARCHY_PROFILE_VERSION, + source_version: profile_selected.then(|| SCCM_HIERARCHY_SOURCE_VERSION.to_owned()), + validated_role: profile_selected.then_some(SccmRole::SiteServer), + synthetic_fixture_only: true, + }, + transactions, + coverage, + source_local_observations, + artifact_requests, + findings, + cross_side_causal_claims: Vec::new(), + native_validation_performed: false, + }) +} + +fn state_chain() -> Vec { + vec![ + SccmHierarchyPhase::Initiate, + SccmHierarchyPhase::QueueOrSerialize, + SccmHierarchyPhase::Send, + SccmHierarchyPhase::Receive, + SccmHierarchyPhase::Process, + SccmHierarchyPhase::Acknowledge, + SccmHierarchyPhase::HealthyOrTerminal, + ] +} + +struct Candidate { + key: SccmHierarchyKey, + correlation_keys: Vec, + observations: Vec, + evidence: Vec, + directions: BTreeSet, +} + +struct ParsedRecord { + phase: SccmHierarchyPhase, + disposition: SccmHierarchyDisposition, + terminal: bool, + message_id: String, + link_id: String, + origin_site: String, + target_site: String, + profile_id: Option, +} + +fn topology_link<'a>( + intake: &'a SccmServerIntakeAssessment, + key: &SccmHierarchyKey, +) -> Option<&'a SccmServerHierarchyLinkTopology> { + intake.topology.hierarchy_links.iter().find(|link| { + link.origin_site_code == key.origin_site_code + && link.target_site_code == key.target_site_code + && link.origin_host_handle != link.target_host_handle + }) +} + +fn record_topology_matches( + intake: &SccmServerIntakeAssessment, + artifact: &SccmServerArtifactAssessment, + parsed: &ParsedRecord, +) -> bool { + let key = SccmHierarchyKey { + message_id: parsed.message_id.clone(), + link_id: parsed.link_id.clone(), + origin_site_code: parsed.origin_site.clone(), + target_site_code: parsed.target_site.clone(), + }; + let Some(link) = topology_link(intake, &key) else { + return false; + }; + match artifact_direction(artifact) { + Some(SccmHierarchyDirection::Origin) => { + artifact.producer_host_handle.as_deref() == Some(link.origin_host_handle.as_str()) + } + Some(SccmHierarchyDirection::Target) => { + artifact.producer_host_handle.as_deref() == Some(link.target_host_handle.as_str()) + } + Some(SccmHierarchyDirection::Both) | None => false, + } +} + +fn sealed_profile_artifact(artifact: &SccmServerArtifactAssessment) -> bool { + artifact.state == SccmCoverageState::Captured + && artifact.parser_eligible + && registered_profile_provenance(artifact) + && declared_source(artifact) +} + +fn registered_profile_provenance(artifact: &SccmServerArtifactAssessment) -> bool { + let provenance_ok = artifact + .capture_provenance + .as_ref() + .is_some_and(|provenance| { + provenance.schema_version == 1 + && provenance.encoding == "utf-8" + && provenance.byte_limit > 0 + && provenance.limit_applied == (artifact.state == SccmCoverageState::Capped) + }); + artifact.profile_eligible + && artifact.source_version.as_deref() == Some(SCCM_HIERARCHY_SOURCE_VERSION) + && artifact.content_sha256.as_deref().is_some_and(|digest| { + SYNTHETIC_PROFILE_PAYLOADS + .iter() + .any(|(artifact_id, expected)| { + *artifact_id == artifact.artifact_id && *expected == digest + }) + }) + && provenance_ok + && chrono::DateTime::parse_from_rfc3339(&artifact.collected_at_utc).is_ok() + && !artifact.rotation_lineage_handle.is_empty() +} + +fn artifact_direction(artifact: &SccmServerArtifactAssessment) -> Option { + match artifact.original_basename.as_deref()? { + "replmgr.log" | "sender.log" | "sender.lo_" => Some(SccmHierarchyDirection::Origin), + "despool.log" | "rcmctrl.log" => Some(SccmHierarchyDirection::Target), + _ => None, + } +} + +fn declared_source(artifact: &SccmServerArtifactAssessment) -> bool { + artifact.producer_role == SccmRole::SiteServer + && matches!( + ( + artifact.source_id.as_str(), + artifact.original_basename.as_deref(), + artifact_direction(artifact) + ), + ( + "server-hierarchy-control", + Some("replmgr.log"), + Some(SccmHierarchyDirection::Origin) + ) | ( + "server-hierarchy-control", + Some("rcmctrl.log"), + Some(SccmHierarchyDirection::Target) + ) | ( + "server-hierarchy-transfer", + Some("sender.log"), + Some(SccmHierarchyDirection::Origin) + ) | ( + "server-hierarchy-transfer", + Some("sender.lo_"), + Some(SccmHierarchyDirection::Origin) + ) | ( + "server-hierarchy-transfer", + Some("despool.log"), + Some(SccmHierarchyDirection::Target) + ) + ) +} + +fn phase_owned(artifact: &SccmServerArtifactAssessment, phase: &SccmHierarchyPhase) -> bool { + matches!( + (artifact.original_basename.as_deref(), phase), + ( + Some("replmgr.log"), + SccmHierarchyPhase::Initiate | SccmHierarchyPhase::QueueOrSerialize + ) | (Some("sender.log" | "sender.lo_"), SccmHierarchyPhase::Send) + | ( + Some("despool.log"), + SccmHierarchyPhase::Receive + | SccmHierarchyPhase::Process + | SccmHierarchyPhase::HealthyOrTerminal + ) + | ( + Some("rcmctrl.log"), + SccmHierarchyPhase::Acknowledge | SccmHierarchyPhase::HealthyOrTerminal + ) + ) +} + +fn extracted_keys_match_record(keys: &[SccmCorrelationKey], parsed: &ParsedRecord) -> bool { + let exact = |kind: SccmCorrelationKeyKind, value: &str| { + keys.iter().filter(|key| key.kind == kind).count() == 1 + && keys.iter().any(|key| { + key.kind == kind + && key.normalized == value + && key.extraction_profile_id.as_deref() == Some(SCCM_HIERARCHY_PROFILE_ID) + }) + }; + let mut sites = keys + .iter() + .filter(|key| key.kind == SccmCorrelationKeyKind::SiteCode) + .map(|key| key.normalized.as_str()) + .collect::>(); + sites.sort_unstable(); + let mut expected_sites = vec![parsed.origin_site.as_str(), parsed.target_site.as_str()]; + expected_sites.sort_unstable(); + exact( + SccmCorrelationKeyKind::HierarchyMessageId, + &parsed.message_id, + ) && exact(SccmCorrelationKeyKind::HierarchyLinkId, &parsed.link_id) + && sites == expected_sites +} + +fn parse_public_record(message: &str) -> Option { + let body = message.strip_prefix(PUBLIC_MESSAGE_PREFIX)?; + let mut fields = BTreeMap::new(); + let mut segments = body.split(';').map(str::trim); + let first = segments.next()?; + let mut pending = Some(first); + if first == FIXTURE_MARKER { + pending = None; + } + const ALLOWED_FIELDS: &[&str] = &[ + "Phase", + "Disposition", + "Terminal", + "MessageId", + "LinkId", + "OriginSite", + "TargetSite", + "ProfileId", + ]; + for segment in pending.into_iter().chain(segments) { + let (name, value) = segment.split_once('=')?; + if !ALLOWED_FIELDS.contains(&name) + || value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + || fields.insert(name, value).is_some() + { + return None; + } + } + let terminal = match *fields.get("Terminal")? { + "true" => true, + "false" => false, + _ => return None, + }; + let phase = SccmHierarchyPhase::parse(fields.get("Phase")?)?; + let disposition = SccmHierarchyDisposition::parse(fields.get("Disposition")?)?; + if (terminal && disposition == SccmHierarchyDisposition::Retrying) + || (terminal + && disposition == SccmHierarchyDisposition::Succeeded + && phase != SccmHierarchyPhase::HealthyOrTerminal) + { + return None; + } + Some(ParsedRecord { + phase, + disposition, + terminal, + message_id: (*fields.get("MessageId")?).to_owned(), + link_id: (*fields.get("LinkId")?).to_owned(), + origin_site: (*fields.get("OriginSite")?).to_owned(), + target_site: (*fields.get("TargetSite")?).to_owned(), + profile_id: fields.get("ProfileId").map(|value| (*value).to_owned()), + }) +} + +fn looks_like_hierarchy_record(message: &str) -> bool { + ["Phase=", "Disposition=", "MessageId=", "LinkId="] + .iter() + .any(|label| message.contains(label)) +} + +fn exact_message_id(value: &str) -> bool { + exact_hierarchy_id(value, "msg-") +} + +fn exact_link_id(value: &str) -> bool { + exact_hierarchy_id(value, "link-") +} + +fn exact_hierarchy_id(value: &str, prefix: &str) -> bool { + value.len() <= 128 + && value.strip_prefix(prefix).is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) +} + +fn exact_site_code(value: &str) -> bool { + value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_uppercase()) +} + +fn transaction_id(key: &SccmHierarchyKey) -> String { + format!( + "hierarchy:{}:{}:{}:{}", + key.message_id, key.origin_site_code, key.target_site_code, key.link_id + ) +} + +fn phase_name(phase: &SccmHierarchyPhase) -> &'static str { + match phase { + SccmHierarchyPhase::Initiate => "initiate", + SccmHierarchyPhase::QueueOrSerialize => "queueOrSerialize", + SccmHierarchyPhase::Send => "send", + SccmHierarchyPhase::Receive => "receive", + SccmHierarchyPhase::Process => "process", + SccmHierarchyPhase::Acknowledge => "acknowledge", + SccmHierarchyPhase::HealthyOrTerminal => "healthyOrTerminal", + } +} + +fn observation_id( + reference: &SccmEvidenceRef, + phase: &SccmHierarchyPhase, + disposition: &SccmHierarchyDisposition, + terminal: bool, +) -> String { + let suffix = if terminal && *disposition == SccmHierarchyDisposition::Succeeded { + "terminal" + } else if *disposition == SccmHierarchyDisposition::Retrying { + "retry" + } else if *phase == SccmHierarchyPhase::QueueOrSerialize { + "queue" + } else if *disposition == SccmHierarchyDisposition::Failed { + "failure" + } else { + phase_name(phase) + }; + format!( + "hierarchy-observation:{}:{}-{}:{suffix}", + reference.artifact_id, + reference.line_start.unwrap_or(0), + reference.line_end.unwrap_or(0) + ) +} + +fn observation_artifact_id(observation: &SccmHierarchyObservation) -> &str { + observation + .evidence + .first() + .map_or("", |reference| reference.artifact_id.as_str()) +} + +fn observation_line_start(observation: &SccmHierarchyObservation) -> Option { + observation + .evidence + .first() + .and_then(|reference| reference.line_start) +} + +fn observation_order( + left: &SccmHierarchyObservation, + right: &SccmHierarchyObservation, +) -> Ordering { + left.phase + .rank() + .cmp(&right.phase.rank()) + .then_with(|| observation_artifact_id(left).cmp(observation_artifact_id(right))) + .then_with(|| observation_line_start(left).cmp(&observation_line_start(right))) + .then_with(|| left.observation_id.cmp(&right.observation_id)) +} + +fn evidence_order(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)) +} + +fn timestamp_ordering(observations: &[SccmHierarchyObservation]) -> SccmHierarchyTimestampOrdering { + for observation in observations { + match observation.timestamp.ordering_state { + SccmTimeOrderingState::NormalizedUtc if observation.timestamp.utc_millis.is_some() => {} + SccmTimeOrderingState::OffsetInvalid => { + return SccmHierarchyTimestampOrdering::UnusableInvalidOffset + } + SccmTimeOrderingState::OffsetMissing => { + return SccmHierarchyTimestampOrdering::UnusableMissingOffset + } + SccmTimeOrderingState::TimestampMissing | SccmTimeOrderingState::NormalizedUtc => { + return SccmHierarchyTimestampOrdering::UnusableMissingTimestamp + } + } + } + for (index, left) in observations.iter().enumerate() { + for right in &observations[index + 1..] { + if left.phase.rank() >= right.phase.rank() { + continue; + } + let (Some(left_millis), Some(right_millis)) = + (left.timestamp.utc_millis, right.timestamp.utc_millis) + else { + return SccmHierarchyTimestampOrdering::UnusableMissingTimestamp; + }; + if left_millis > right_millis { + return SccmHierarchyTimestampOrdering::Contradictory; + } + if left_millis == right_millis { + let same_artifact = observation_artifact_id(left) == observation_artifact_id(right); + let physical_order = same_artifact + && observation_line_start(left) + .zip(observation_line_start(right)) + .is_some_and(|(left_line, right_line)| left_line < right_line); + if !physical_order { + return SccmHierarchyTimestampOrdering::Contradictory; + } + } + } + } + SccmHierarchyTimestampOrdering::Usable +} + +fn has_contradiction(observations: &[SccmHierarchyObservation]) -> bool { + observations.iter().enumerate().any(|(index, left)| { + observations[index + 1..].iter().any(|right| { + left.phase == right.phase + && (matches!( + (&left.disposition, &right.disposition), + ( + SccmHierarchyDisposition::Succeeded, + SccmHierarchyDisposition::Failed + ) | ( + SccmHierarchyDisposition::Failed, + SccmHierarchyDisposition::Succeeded + ) + ) || (left.disposition == right.disposition && left.terminal != right.terminal)) + }) + }) +} + +fn missing_required_artifacts( + intake: &SccmServerIntakeAssessment, + artifacts: &[&SccmServerArtifactAssessment], + key: &SccmHierarchyKey, +) -> Vec { + let Some(link) = topology_link(intake, key) else { + return Vec::new(); + }; + let mut gaps = artifacts + .iter() + .filter(|artifact| artifact.state != SccmCoverageState::Captured) + .filter(|artifact| { + let producer = artifact.producer_host_handle.as_deref(); + match artifact_direction(artifact) { + Some(SccmHierarchyDirection::Origin) => { + producer == Some(link.origin_host_handle.as_str()) + } + Some(SccmHierarchyDirection::Target) => { + producer == Some(link.target_host_handle.as_str()) + } + Some(SccmHierarchyDirection::Both) | None => false, + } + }) + .map(|artifact| artifact.artifact_id.clone()) + .collect::>(); + gaps.sort(); + gaps +} + +fn source_local( + artifact: &SccmServerArtifactAssessment, + reason_code: &str, + evidence: Vec, +) -> SccmHierarchySourceLocalObservation { + let evidence_identity = evidence.first().map_or_else( + || "no-line".to_owned(), + |reference| { + format!( + "{}-{}", + reference.line_start.unwrap_or(0), + reference.line_end.unwrap_or(0) + ) + }, + ); + SccmHierarchySourceLocalObservation { + observation_id: format!( + "hierarchy-source:{}:{evidence_identity}:{reason_code}", + artifact.artifact_id + ), + finding_class: SccmFindingClass::InsufficientEvidence, + confidence: SccmConfidence::Low, + correlation_eligible: false, + artifact_ids: vec![artifact.artifact_id.clone()], + evidence, + reason_code: reason_code.to_owned(), + } +} + +fn coverage_requests( + intake: &SccmServerIntakeAssessment, + artifacts: &[&SccmServerArtifactAssessment], +) -> Vec { + let mut requests = Vec::new(); + for artifact in artifacts { + let reason_code = match artifact.state { + SccmCoverageState::Absent => Some("coverageAbsent"), + SccmCoverageState::Capped => Some("coverageCapped"), + SccmCoverageState::AccessDenied => Some("coverageAccessDenied"), + SccmCoverageState::ParseFailed => Some("coverageParseFailed"), + _ => None, + }; + let Some(reason_code) = reason_code else { + continue; + }; + let Some((direction, origin_site_code, target_site_code)) = + exact_artifact_scope(intake, artifact) + else { + continue; + }; + requests.push(SccmHierarchyArtifactRequest { + transaction_id: None, + source_id: artifact.source_id.clone(), + producer_role: artifact.producer_role.clone(), + direction, + origin_site_code, + target_site_code, + basenames: artifact.original_basename.iter().cloned().collect(), + reason_code: reason_code.to_owned(), + }); + } + requests.sort_by(request_order); + requests.dedup_by(|left, right| request_order(left, right).is_eq()); + requests +} + +fn missing_target_source_requests( + artifacts: &[&SccmServerArtifactAssessment], + topology: &SccmServerHierarchyLinkTopology, + transaction_id: &str, +) -> Vec { + [ + ("server-hierarchy-transfer", "despool.log"), + ("server-hierarchy-control", "rcmctrl.log"), + ] + .into_iter() + .filter(|(source_id, basename)| { + !artifacts.iter().any(|artifact| { + sealed_profile_artifact(artifact) + && artifact.source_id == *source_id + && artifact.original_basename.as_deref() == Some(*basename) + && artifact_direction(artifact) == Some(SccmHierarchyDirection::Target) + && artifact.producer_host_handle.as_deref() + == Some(topology.target_host_handle.as_str()) + }) + }) + .map(|(source_id, basename)| SccmHierarchyArtifactRequest { + transaction_id: Some(transaction_id.to_owned()), + source_id: source_id.to_owned(), + producer_role: SccmRole::SiteServer, + direction: SccmHierarchyDirection::Target, + origin_site_code: topology.origin_site_code.clone(), + target_site_code: topology.target_site_code.clone(), + basenames: vec![basename.to_owned()], + reason_code: MISSING_TARGET_REASON_CODE.to_owned(), + }) + .collect() +} + +fn exact_artifact_scope( + intake: &SccmServerIntakeAssessment, + artifact: &SccmServerArtifactAssessment, +) -> Option<(SccmHierarchyDirection, String, String)> { + let direction = artifact_direction(artifact)?; + let host = artifact.producer_host_handle.as_deref()?; + let mut targets = intake + .topology + .hierarchy_links + .iter() + .filter(|link| match direction { + SccmHierarchyDirection::Origin => link.origin_host_handle == host, + SccmHierarchyDirection::Target => link.target_host_handle == host, + SccmHierarchyDirection::Both => false, + }) + .map(|link| (link.origin_site_code.clone(), link.target_site_code.clone())) + .collect::>(); + targets.sort(); + targets.dedup(); + (targets.len() == 1).then(|| { + let (origin_site_code, target_site_code) = targets.remove(0); + (direction, origin_site_code, target_site_code) + }) +} + +fn rotation_split_outputs( + intake: &SccmServerIntakeAssessment, + artifacts: &[&SccmServerArtifactAssessment], +) -> ( + Vec, + Vec, +) { + type RotationKey = ( + String, + String, + SccmHierarchyDirection, + String, + String, + String, + ); + let mut groups = BTreeMap::>::new(); + for artifact in artifacts { + if artifact.rotation_lineage_handle.is_empty() + || !matches!( + artifact.state, + SccmCoverageState::Captured + | SccmCoverageState::Capped + | SccmCoverageState::ParseFailed + ) + { + continue; + } + let Some((direction, origin_site_code, target_site_code)) = + exact_artifact_scope(intake, artifact) + else { + continue; + }; + let Some(host) = artifact.producer_host_handle.clone() else { + continue; + }; + groups + .entry(( + artifact.source_id.clone(), + host, + direction, + origin_site_code, + target_site_code, + artifact.rotation_lineage_handle.clone(), + )) + .or_default() + .push(artifact); + } + + let mut observations = Vec::new(); + let mut requests = Vec::new(); + for ((source_id, _, direction, origin_site_code, target_site_code, lineage), mut pair) in groups + { + if pair.len() != 2 || !canonical_current_lo_pair(&pair) { + continue; + } + pair.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + let artifact_ids = pair + .iter() + .map(|artifact| artifact.artifact_id.clone()) + .collect::>(); + let mut basenames = pair + .iter() + .filter_map(|artifact| artifact.original_basename.clone()) + .collect::>(); + basenames.sort(); + observations.push(SccmHierarchySourceLocalObservation { + observation_id: format!( + "hierarchy-rotation:{}:{}:{}", + artifact_ids[0], artifact_ids[1], lineage + ), + finding_class: SccmFindingClass::InsufficientEvidence, + confidence: SccmConfidence::Low, + correlation_eligible: false, + artifact_ids, + evidence: Vec::new(), + reason_code: "rotationSplit".to_owned(), + }); + requests.push(SccmHierarchyArtifactRequest { + transaction_id: None, + source_id, + producer_role: SccmRole::SiteServer, + direction, + origin_site_code, + target_site_code, + basenames, + reason_code: "coverageRotationSplit".to_owned(), + }); + } + (observations, requests) +} + +fn invalid_time_requests( + intake: &SccmServerIntakeAssessment, + transaction: &SccmHierarchyTransaction, +) -> Vec { + let reason_code = match transaction.timestamp_ordering { + SccmHierarchyTimestampOrdering::UnusableInvalidOffset + | SccmHierarchyTimestampOrdering::UnusableMissingOffset + | SccmHierarchyTimestampOrdering::UnusableMissingTimestamp => "invalidOffset", + SccmHierarchyTimestampOrdering::Contradictory => "contradictoryOrdering", + SccmHierarchyTimestampOrdering::Usable => return Vec::new(), + }; + let mut groups = + BTreeMap::, BTreeSet)>::new(); + for reference in &transaction.evidence { + let Some(artifact) = intake + .artifacts + .iter() + .find(|artifact| artifact.artifact_id == reference.artifact_id) + else { + continue; + }; + let (Some(direction), Some(basename)) = ( + artifact_direction(artifact), + artifact.original_basename.as_ref(), + ) else { + continue; + }; + let group = groups.entry(artifact.source_id.clone()).or_default(); + group.0.insert(direction); + group.1.insert(basename.clone()); + } + groups + .into_iter() + .map( + |(source_id, (directions, basenames))| SccmHierarchyArtifactRequest { + transaction_id: Some(transaction.transaction_id.clone()), + source_id, + producer_role: SccmRole::SiteServer, + direction: if directions.len() == 2 { + SccmHierarchyDirection::Both + } else { + directions + .into_iter() + .next() + .unwrap_or(SccmHierarchyDirection::Origin) + }, + origin_site_code: transaction.key.origin_site_code.clone(), + target_site_code: transaction.key.target_site_code.clone(), + basenames: basenames.into_iter().collect(), + reason_code: reason_code.to_owned(), + }, + ) + .collect() +} + +fn canonical_current_lo_pair(pair: &[&SccmServerArtifactAssessment]) -> bool { + let current = pair + .iter() + .filter(|artifact| artifact.rotation == Some(SccmRotation::Current)) + .count(); + let lo = pair + .iter() + .filter(|artifact| artifact.rotation == Some(SccmRotation::LoUnderscore)) + .count(); + current == 1 + && lo == 1 + && pair.iter().all(|artifact| { + artifact.source_id == pair[0].source_id + && artifact.producer_role == pair[0].producer_role + && artifact.producer_host_handle == pair[0].producer_host_handle + && artifact.rotation_lineage_handle == pair[0].rotation_lineage_handle + }) +} + +fn build_finding( + transaction: &SccmHierarchyTransaction, + coverage: &[SccmHierarchyCoverage], +) -> Result, SccmHierarchyError> { + let Some(mut class) = transaction.finding_class.clone() else { + return Ok(None); + }; + let coverage_gaps = transaction + .coverage_gap_artifact_ids + .iter() + .filter_map(|artifact_id| { + coverage + .iter() + .find(|coverage| &coverage.artifact_id == artifact_id) + .map(|coverage| SccmFindingCoverageGap { + artifact_id: artifact_id.clone(), + role: SccmRole::SiteServer, + coverage: coverage.state.clone(), + }) + }) + .collect::>(); + if class == SccmFindingClass::InsufficientEvidence && coverage_gaps.is_empty() { + class = SccmFindingClass::Symptom; + } + let terminal_evidence = if class == SccmFindingClass::ConfirmedFailure { + transaction + .observations + .iter() + .filter(|observation| { + observation.terminal && observation.disposition == SccmHierarchyDisposition::Failed + }) + .flat_map(|observation| observation.evidence.iter().cloned()) + .map(SccmTerminalEvidence::observed_failure) + .collect() + } else { + Vec::new() + }; + let next_artifacts = shared_requests(&transaction.next_artifacts); + let finding = SccmFindingBuilder::new(format!("hierarchy-finding:{}", transaction.transaction_id)) + .class(class) + .phase(SccmPhase::Unknown(format!( + "hierarchy{}", + transaction + .last_successful_phase + .as_ref() + .map(phase_name) + .unwrap_or("Unconfirmed") + ))) + .role(SccmRole::SiteServer) + .severity(if transaction.state == SccmHierarchyState::Failed { + Severity::Error + } else { + Severity::Warning + }) + .confidence(transaction.confidence) + .title("Hierarchy replication evidence") + .summary("The hierarchy outcome is bounded to canonical intake evidence and the selected transaction topology.") + .evidence(transaction.evidence.clone()) + .terminal_evidence(terminal_evidence) + .coverage_gaps(coverage_gaps) + .correlation_keys(transaction.correlation_keys.clone()) + .next_artifacts(next_artifacts) + .build() + .map_err(|_| SccmHierarchyError::InvalidFindingContract)?; + Ok(Some(finding)) +} + +fn shared_requests(requests: &[SccmHierarchyArtifactRequest]) -> Vec { + let mut shared = requests + .iter() + .flat_map(|request| request.basenames.iter()) + .filter_map(|basename| { + let logical_id = basename + .strip_suffix(".log") + .or_else(|| basename.strip_suffix(".lo_"))?; + Some(SccmArtifactRequest { + logical_id: logical_id.to_owned(), + role: SccmRole::SiteServer, + reason: format!("Collect the complete {basename} file."), + }) + }) + .collect::>(); + shared.sort_by(|left, right| { + left.logical_id + .cmp(&right.logical_id) + .then_with(|| left.reason.cmp(&right.reason)) + }); + shared.dedup(); + shared +} + +fn correlation_key_order(left: &SccmCorrelationKey, right: &SccmCorrelationKey) -> Ordering { + hierarchy_key_order(&left.kind) + .cmp(&hierarchy_key_order(&right.kind)) + .then_with(|| left.normalized.cmp(&right.normalized)) + .then_with(|| { + left.evidence + .as_ref() + .map(|reference| reference.entry_id.as_str()) + .cmp( + &right + .evidence + .as_ref() + .map(|reference| reference.entry_id.as_str()), + ) + }) +} + +fn hierarchy_key_order(kind: &SccmCorrelationKeyKind) -> u8 { + match kind { + SccmCorrelationKeyKind::HierarchyLinkId => 0, + SccmCorrelationKeyKind::HierarchyMessageId => 1, + SccmCorrelationKeyKind::SiteCode => 2, + SccmCorrelationKeyKind::AssignmentId => 3, + SccmCorrelationKeyKind::PolicyId => 4, + SccmCorrelationKeyKind::ClientGuid => 5, + SccmCorrelationKeyKind::PackageId => 6, + SccmCorrelationKeyKind::ContentId => 7, + SccmCorrelationKeyKind::ServerHost => 8, + SccmCorrelationKeyKind::CiId => 9, + SccmCorrelationKeyKind::UpdateId => 10, + SccmCorrelationKeyKind::KbId => 11, + SccmCorrelationKeyKind::BitsJobId => 12, + SccmCorrelationKeyKind::TaskSequenceExecutionId => 13, + SccmCorrelationKeyKind::RequestId => 14, + SccmCorrelationKeyKind::TopicId => 15, + SccmCorrelationKeyKind::StateMessageId => 16, + SccmCorrelationKeyKind::InventoryCycleId => 17, + SccmCorrelationKeyKind::ReportId => 18, + SccmCorrelationKeyKind::ResourceHandle => 19, + SccmCorrelationKeyKind::ComplianceCiId => 20, + SccmCorrelationKeyKind::BaselineId => 21, + SccmCorrelationKeyKind::ComplianceStateId => 22, + SccmCorrelationKeyKind::MeteringCycleId => 23, + SccmCorrelationKeyKind::RuleId => 24, + } +} + +fn request_order( + left: &SccmHierarchyArtifactRequest, + right: &SccmHierarchyArtifactRequest, +) -> Ordering { + ( + left.transaction_id.as_deref(), + left.source_id.as_str(), + &left.direction, + left.origin_site_code.as_str(), + left.target_site_code.as_str(), + left.reason_code.as_str(), + &left.basenames, + ) + .cmp(&( + right.transaction_id.as_deref(), + right.source_id.as_str(), + &right.direction, + right.origin_site_code.as_str(), + right.target_site_code.as_str(), + right.reason_code.as_str(), + &right.basenames, + )) +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs new file mode 100644 index 000000000..103e015c7 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -0,0 +1,3836 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::io::{self, Write}; + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::de::{Error as _, MapAccess, SeqAccess, Visitor}; +use serde::ser::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::sccm::rotation::is_canonical_rotation_timestamp; +use crate::sccm::{ + classify_artifact_name, normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, + SccmArtifactRequest, SccmCoverageState, SccmEvidence, SccmFinding, SccmRole, SccmRotation, +}; + +use super::catalog::{ + classify_declared_server_source, expected_family, SccmServerSourceKind, SccmServerSourceSpec, +}; + +/// Keep parser-side work bounded even when the manifest did not come from the +/// native collector. These match the existing bounded bundle intake envelope. +pub const MAX_SCCM_SERVER_MANIFEST_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_SCCM_SERVER_MANIFEST_ARTIFACTS: usize = 512; +pub const MAX_SCCM_SERVER_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024; +pub const MAX_SCCM_SERVER_TOTAL_DECLARED_BYTES: u64 = 1024 * 1024 * 1024; +const MAX_SCCM_SERVER_OPAQUE_EXTENSIONS: usize = 32; +const MAX_SCCM_SERVER_OPAQUE_EXTENSION_BYTES_PER_SCOPE: usize = 8 * 1024; +const MAX_SCCM_SERVER_TOTAL_OPAQUE_EXTENSIONS: usize = 1_024; +const MAX_SCCM_SERVER_TOTAL_OPAQUE_EXTENSION_BYTES: usize = 256 * 1024; + +// Closed synthetic hierarchy vocabulary from the reviewed #331 corpus. These +// values are intake fixtures, not a prefix admission rule: adding one requires +// changing this registry and therefore the canonical intake integrity surface. +const SYNTHETIC_HIERARCHY_ARTIFACT_IDS: &[&str] = &[ + "absent-01-sender", + "absent-02-despool", + "backlog-01-replmgr", + "clock-01-sender", + "clock-02-despool", + "generic-01-sender", + "healthy-01-replmgr", + "healthy-02-sender", + "healthy-03-despool", + "healthy-04-rcmctrl", + "incomplete-01-replmgr", + "mismatch-01-sender", + "mismatch-02-despool", + "receiver-01-sender", + "receiver-02-despool", + "recovery-01-sender", + "recovery-02-despool", + "rotation-01-current", + "rotation-02-lo", + "sender-failure-01-chd", +]; +const SYNTHETIC_HIERARCHY_LINEAGES: &[&str] = &[ + "absent-despool", + "absent-sender", + "backlog-replmgr", + "clock-despool", + "clock-sender", + "generic-site-token-sender", + "healthy-despool", + "healthy-rcmctrl", + "healthy-replmgr", + "healthy-sender", + "incomplete-replmgr", + "mismatch-despool", + "mismatch-sender", + "receiver-despool", + "receiver-sender", + "recovery-despool", + "recovery-sender", + "rotation-sender", + "sender-failure", +]; +const SYNTHETIC_HIERARCHY_PATH_FINGERPRINTS: &[&str] = &[ + "synthetic:absent-despool", + "synthetic:absent-sender", + "synthetic:backlog-replmgr", + "synthetic:clock-despool", + "synthetic:clock-sender", + "synthetic:generic-site-token-sender", + "synthetic:healthy-despool", + "synthetic:healthy-rcmctrl", + "synthetic:healthy-replmgr", + "synthetic:healthy-sender", + "synthetic:incomplete-replmgr", + "synthetic:mismatch-despool", + "synthetic:mismatch-sender", + "synthetic:receiver-despool", + "synthetic:receiver-sender", + "synthetic:recovery-despool", + "synthetic:recovery-sender", + "synthetic:rotation-current", + "synthetic:rotation-lo", + "synthetic:sender-failure-current", +]; + +type PathFingerprintKey = ( + String, + Option, + String, + String, + Option, + String, +); +type CanonicalArtifactIdentity = ( + String, + Option, + String, + String, + Option, + String, + String, + String, + String, +); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SccmServerArtifactPayload { + pub manifest_artifact_id: String, + pub bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmServerIntakeAssessment { + pub schema_version: u32, + pub topology: SccmServerTopologyAssessment, + pub artifacts: Vec, + pub coverage: Vec, + pub evidence: Vec, + pub findings: Vec, + pub next_artifact_requests: Vec, + /// Private integrity binding for the canonical projection that server-role + /// reducers consume. It is sequence-independent, but every authoritative + /// schema, topology, artifact, coverage, and evidence field remains bound + /// to the assessment produced by intake. + intake_integrity: SccmServerIntakeIntegrity, + /// Versioned opaque manifest extensions retained without interpreting them. + extensions: Vec, + privacy_extensions: Vec, +} + +/// A public, deterministic representation of a recognized opaque manifest extension. +/// +/// Extension names use `x-cmtraceopen-opaque-v1-` and values use +/// `cmtraceopen.extension.sha256.v1:<64 lowercase hexadecimal characters>`. +/// The parser retains this provenance but never interprets it as diagnostic input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SccmServerOpaqueExtension { + schema_version: u32, + name: String, + value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SccmServerOpaqueExtensionError { + #[error("unsupported opaque extension schema version {schema_version}")] + UnsupportedSchemaVersion { schema_version: u32 }, + #[error("opaque extension name is invalid or unsafe")] + InvalidName, + #[error("opaque extension value is invalid or unsafe")] + InvalidValue, +} + +impl SccmServerOpaqueExtension { + pub fn try_new( + name: impl Into, + value: impl Into, + ) -> Result { + let extension = Self { + schema_version: 1, + name: name.into(), + value: value.into(), + }; + extension.validate()?; + Ok(extension) + } + + pub fn schema_version(&self) -> u32 { + self.schema_version + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn value(&self) -> &str { + &self.value + } + + fn validate(&self) -> Result<(), SccmServerOpaqueExtensionError> { + if self.schema_version != 1 { + return Err(SccmServerOpaqueExtensionError::UnsupportedSchemaVersion { + schema_version: self.schema_version, + }); + } + if !safe_opaque_extension_name(&self.name) { + return Err(SccmServerOpaqueExtensionError::InvalidName); + } + if !opaque_sha256_handle(&self.value, "cmtraceopen.extension.sha256.v1:") { + return Err(SccmServerOpaqueExtensionError::InvalidValue); + } + Ok(()) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SccmServerOpaqueExtensionSerializeWire<'a> { + schema_version: u32, + name: &'a str, + value: &'a str, +} + +impl Serialize for SccmServerOpaqueExtension { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.validate().map_err(S::Error::custom)?; + SccmServerOpaqueExtensionSerializeWire { + schema_version: self.schema_version, + name: &self.name, + value: &self.value, + } + .serialize(serializer) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmServerOpaqueExtensionDeserializeWire { + schema_version: u32, + name: String, + value: String, +} + +impl<'de> Deserialize<'de> for SccmServerOpaqueExtension { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = SccmServerOpaqueExtensionDeserializeWire::deserialize(deserializer)?; + let extension = Self { + schema_version: wire.schema_version, + name: wire.name, + value: wire.value, + }; + extension.validate().map_err(D::Error::custom)?; + Ok(extension) + } +} + +impl SccmServerIntakeAssessment { + pub fn extensions(&self) -> &[SccmServerOpaqueExtension] { + &self.extensions + } + + pub fn privacy_extensions(&self) -> &[SccmServerOpaqueExtension] { + &self.privacy_extensions + } + + pub(crate) fn topology_authority_is_intake_bound(&self) -> bool { + if self.schema_version != self.intake_integrity.schema_version + || self.topology.roles_observed.len() != self.intake_integrity.topology_role_count + || topology_string_bytes(&self.topology) + != Some(self.intake_integrity.structure.topology_string_bytes) + { + return false; + } + let Some(normalized_topology) = normalized_topology_or_none(&self.topology) else { + return false; + }; + canonical_record_digest_bounded( + b"topology", + &normalized_topology, + Some(self.intake_integrity.topology.payload_len), + ) + .as_ref() + .is_some_and(|topology| topology == &self.intake_integrity.topology) + } + + pub(crate) fn adapter_authority_is_intake_bound(&self) -> bool { + if self.schema_version != self.intake_integrity.schema_version + || self.topology.roles_observed.len() != self.intake_integrity.topology_role_count + || self.artifacts.len() != self.intake_integrity.artifacts.len() + || self.coverage.len() != self.intake_integrity.coverage.len() + || self.evidence.len() != self.intake_integrity.evidence.len() + { + return false; + } + canonical_intake_integrity_for_adapter( + self.schema_version, + &self.topology, + &self.artifacts, + &self.coverage, + &self.evidence, + &self.intake_integrity, + ) + .as_ref() + .is_some_and(|integrity| integrity == &self.intake_integrity) + } +} + +fn normalized_topology_or_none( + topology: &SccmServerTopologyAssessment, +) -> Option { + let mut normalized = topology.clone(); + normalized + .roles_observed + .sort_by(|left, right| role_sort_key(left).cmp(role_sort_key(right))); + if normalized + .roles_observed + .windows(2) + .any(|roles| roles[0] == roles[1]) + { + return None; + } + normalized.hierarchy_links.sort(); + if normalized + .hierarchy_links + .windows(2) + .any(|links| links[0] == links[1]) + { + return None; + } + Some(normalized) +} + +/// Nonserialized canonical-input binding for downstream server-role adapters. +/// Collection order is not authority: the normalized records are serialized +/// independently and compared as duplicate-free sets. +#[derive(Debug, Clone, PartialEq, Eq)] +struct SccmServerIntakeIntegrity { + schema_version: u32, + topology_role_count: usize, + structure: IntakeIntegrityStructure, + topology: IntakeIntegrityRecord, + artifacts: BTreeMap, + coverage: BTreeMap, + evidence: BTreeMap, +} + +type IntakeIntegrityDigest = [u8; 32]; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IntakeIntegrityRecord { + payload_len: u64, + digest: IntakeIntegrityDigest, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IntakeIntegrityStructure { + // Exact aggregate lengths keep the adapter preflight allocation-free and + // order-independent. Any one caller-mutated string is therefore bounded + // by the canonical aggregate before canonical JSON is visited. + topology_string_bytes: usize, + artifact_string_bytes: usize, + coverage_string_bytes: usize, + evidence_string_bytes: usize, + coverage_artifact_memberships: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct ArtifactIntegrityIdentity(String); + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct CoverageIdentityKey { + producer_role: String, + producer_host_handle: Option, + source_id: String, + workflow_subject_role: Option, + workflow_subject_handle: Option, + state: String, +} + +impl CoverageIdentityKey { + fn new( + producer_role: &SccmRole, + producer_host_handle: Option<&str>, + source_id: &str, + workflow_subject_role: Option<&SccmRole>, + workflow_subject_handle: Option<&str>, + state: &SccmCoverageState, + ) -> Self { + Self { + producer_role: role_sort_key(producer_role).to_owned(), + producer_host_handle: producer_host_handle.map(str::to_owned), + source_id: source_id.to_owned(), + workflow_subject_role: workflow_subject_role.map(|role| role_sort_key(role).to_owned()), + workflow_subject_handle: workflow_subject_handle.map(str::to_owned), + state: coverage_sort_key(state).to_owned(), + } + } + + pub(super) fn from_artifact(artifact: &SccmServerArtifactAssessment) -> Self { + Self::new( + &artifact.producer_role, + artifact.producer_host_handle.as_deref(), + &artifact.source_id, + artifact.workflow_subject_role.as_ref(), + artifact.workflow_subject_handle.as_deref(), + &artifact.state, + ) + } + + pub(super) fn from_coverage(coverage: &SccmServerCoverage) -> Self { + Self::new( + &coverage.producer_role, + coverage.producer_host_handle.as_deref(), + &coverage.source_id, + coverage.workflow_subject_role.as_ref(), + coverage.workflow_subject_handle.as_deref(), + &coverage.state, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct EvidenceIntegrityIdentity(String); + +impl std::borrow::Borrow for ArtifactIntegrityIdentity { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl std::borrow::Borrow for EvidenceIntegrityIdentity { + fn borrow(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +impl SccmServerIntakeIntegrity { + fn retained_material_bytes(&self) -> usize { + std::mem::size_of_val(&self.schema_version) + + std::mem::size_of_val(&self.topology_role_count) + + std::mem::size_of_val(&self.structure) + + std::mem::size_of_val(&self.topology.payload_len) + + self.topology.digest.len() + + self + .artifacts + .iter() + .map(|(identity, record)| { + identity.0.len() + + std::mem::size_of_val(&record.payload_len) + + record.digest.len() + }) + .sum::() + + self + .coverage + .iter() + .map(|(identity, record)| { + identity.producer_role.len() + + identity.producer_host_handle.as_deref().map_or(0, str::len) + + identity.source_id.len() + + identity + .workflow_subject_role + .as_deref() + .map_or(0, str::len) + + identity + .workflow_subject_handle + .as_deref() + .map_or(0, str::len) + + identity.state.len() + + std::mem::size_of_val(&record.payload_len) + + record.digest.len() + }) + .sum::() + + self + .evidence + .iter() + .map(|(identity, record)| { + identity.0.len() + + std::mem::size_of_val(&record.payload_len) + + record.digest.len() + }) + .sum::() + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SccmServerIntakeAssessmentSerializeWire<'a> { + schema_version: u32, + topology: &'a SccmServerTopologyAssessment, + artifacts: &'a [SccmServerArtifactAssessment], + coverage: &'a [SccmServerCoverage], + evidence: &'a [SccmEvidence], + findings: &'a [SccmFinding], + next_artifact_requests: &'a [SccmArtifactRequest], + #[serde(skip_serializing_if = "<[SccmServerOpaqueExtension]>::is_empty")] + extensions: &'a [SccmServerOpaqueExtension], + #[serde(skip_serializing_if = "<[SccmServerOpaqueExtension]>::is_empty")] + privacy_extensions: &'a [SccmServerOpaqueExtension], +} + +impl Serialize for SccmServerIntakeAssessment { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + validate_assessment_extensions(self).map_err(S::Error::custom)?; + SccmServerIntakeAssessmentSerializeWire { + schema_version: self.schema_version, + topology: &self.topology, + artifacts: &self.artifacts, + coverage: &self.coverage, + evidence: &self.evidence, + findings: &self.findings, + next_artifact_requests: &self.next_artifact_requests, + extensions: &self.extensions, + privacy_extensions: &self.privacy_extensions, + } + .serialize(serializer) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmServerTopologyAssessment { + pub capture_host_handle: String, + pub site_handle: String, + pub roles_observed: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub hierarchy_links: Vec, + #[serde( + skip_serializing_if = "Vec::is_empty", + serialize_with = "serialize_opaque_extensions" + )] + extensions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmServerHierarchyLinkTopology { + pub origin_site_code: String, + pub target_site_code: String, + pub origin_host_handle: String, + pub target_host_handle: String, +} + +impl SccmServerTopologyAssessment { + pub fn extensions(&self) -> &[SccmServerOpaqueExtension] { + &self.extensions + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmServerArtifactAssessment { + pub artifact_id: String, + pub producer_role: SccmRole, + pub producer_host_handle: Option, + pub workflow_subject_role: Option, + pub workflow_subject_handle: Option, + pub source_id: String, + pub source_kind: String, + pub family: SccmArtifactFamily, + pub original_basename: Option, + pub rotation: Option, + pub rotation_lineage_handle: String, + pub state: SccmCoverageState, + pub configured_path_state: SccmServerConfiguredPathState, + pub configured_path_class: Option, + pub path_fingerprint: String, + pub source_version: Option, + pub profile_eligible: bool, + pub collected_at_utc: String, + pub relative_path: Option, + pub bytes_copied: u64, + pub content_sha256: Option, + pub truncated: Option, + pub fragment_complete: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub collection_limit: Option, + pub capture_provenance: Option, + pub parser_eligible: bool, + #[serde( + skip_serializing_if = "Vec::is_empty", + serialize_with = "serialize_opaque_extensions" + )] + extensions: Vec, + #[serde( + skip_serializing_if = "Vec::is_empty", + serialize_with = "serialize_opaque_extensions" + )] + workflow_subject_extensions: Vec, + #[serde( + skip_serializing_if = "Vec::is_empty", + serialize_with = "serialize_opaque_extensions" + )] + configured_path_provenance_extensions: Vec, + #[serde( + skip_serializing_if = "Vec::is_empty", + serialize_with = "serialize_opaque_extensions" + )] + rotation_extensions: Vec, + #[serde( + skip_serializing_if = "Vec::is_empty", + serialize_with = "serialize_opaque_extensions" + )] + collection_limit_extensions: Vec, +} + +impl SccmServerArtifactAssessment { + pub fn extensions(&self) -> &[SccmServerOpaqueExtension] { + &self.extensions + } + + pub fn workflow_subject_extensions(&self) -> &[SccmServerOpaqueExtension] { + &self.workflow_subject_extensions + } + + pub fn configured_path_provenance_extensions(&self) -> &[SccmServerOpaqueExtension] { + &self.configured_path_provenance_extensions + } + + pub fn rotation_extensions(&self) -> &[SccmServerOpaqueExtension] { + &self.rotation_extensions + } + + pub fn collection_limit_extensions(&self) -> &[SccmServerOpaqueExtension] { + &self.collection_limit_extensions + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmServerCaptureProvenance { + pub schema_version: u32, + pub encoding: String, + pub byte_limit: u64, + pub file_limit: Option, + pub limit_applied: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmServerCollectionLimit { + pub byte_limit: u64, + pub file_limit: Option, + pub limit_applied: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmServerConfiguredPathState { + Configured, + DefaultCandidate, + NotRequested, + Supplied, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmServerConfiguredPathClass { + NonDefault, +} + +/// A deterministic artifact-membership row in server intake assessment schema v1. +/// +/// Rows emitted by [`assess_server_intake`] bind membership to the validated, +/// privacy-safe producer and workflow-subject topology. The two handle fields +/// are optional additive schema-v1 JSON fields and are omitted when absent; +/// neither contains a raw host name or path. +/// +/// Adding these public fields is not Rust struct-literal source compatible, +/// and strict JSON consumers that reject unknown fields must recognize them +/// before consuming rows where they are present. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmServerCoverage { + pub producer_role: SccmRole, + #[serde(skip_serializing_if = "Option::is_none")] + pub producer_host_handle: Option, + pub workflow_subject_role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workflow_subject_handle: Option, + pub source_id: String, + pub state: SccmCoverageState, + pub artifact_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SccmServerIntakeError { + #[error("server manifest is malformed")] + MalformedManifest, + #[error("server manifest version is unsupported")] + UnsupportedManifestVersion, + #[error("server manifest bundle role is invalid")] + InvalidBundleRole, + #[error("server manifest topology is invalid or unsafe")] + InvalidTopology, + #[error("server manifest artifact contract is invalid or unsafe")] + InvalidArtifact, + #[error("server manifest contains a duplicate artifact identity")] + DuplicateArtifact, + #[error("server artifact payload is missing")] + MissingPayload, + #[error("server artifact payload is unexpected")] + UnexpectedPayload, + #[error("server artifact payload length does not match manifest provenance")] + PayloadLengthMismatch, + #[error("server artifact payload encoding is unsupported or malformed")] + InvalidPayloadEncoding, + #[error("server manifest exceeds a bounded intake limit")] + ManifestLimitExceeded, +} + +pub fn normalize_server_bundle( + manifest_json: &str, + payloads: &[SccmServerArtifactPayload], +) -> Result { + assess_server_intake(manifest_json, payloads) +} + +pub fn assess_server_intake( + manifest_json: &str, + payloads: &[SccmServerArtifactPayload], +) -> Result { + if manifest_json.len() > MAX_SCCM_SERVER_MANIFEST_BYTES { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + preflight_server_manifest_extensions(manifest_json)?; + let manifest: RawServerManifest = serde_json::from_str(manifest_json) + .map_err(|_| SccmServerIntakeError::MalformedManifest)?; + if manifest.sccm_manifest_version != 1 { + return Err(SccmServerIntakeError::UnsupportedManifestVersion); + } + if manifest.bundle_role != "server" { + return Err(SccmServerIntakeError::InvalidBundleRole); + } + validate_manifest_metadata(&manifest)?; + validate_manifest_bounds(&manifest, payloads)?; + + let topology = normalize_topology(&manifest)?; + if topology.roles_observed.iter().any(|role| { + is_opaque_future_role(role) + && !manifest + .artifacts + .iter() + .any(|artifact| artifact.producer_role == *role) + }) { + return Err(SccmServerIntakeError::InvalidTopology); + } + let mut payload_by_id = BTreeMap::new(); + for payload in payloads { + if !safe_manifest_artifact_id(&payload.manifest_artifact_id, manifest.synthetic_fixture) + || payload_by_id + .insert( + payload.manifest_artifact_id.as_str(), + payload.bytes.as_slice(), + ) + .is_some() + { + return Err(SccmServerIntakeError::UnexpectedPayload); + } + } + + let mut manifest_artifact_ids = BTreeSet::new(); + let mut relative_paths = BTreeSet::new(); + let mut path_fingerprint_lineages = BTreeMap::new(); + let mut canonical_artifact_identities = BTreeSet::new(); + let mut prepared = Vec::with_capacity(manifest.artifacts.len()); + for artifact in manifest.artifacts { + if !manifest_artifact_ids.insert(artifact.artifact_id.clone()) { + return Err(SccmServerIntakeError::DuplicateArtifact); + } + let normalized = normalize_artifact( + artifact, + manifest.synthetic_fixture, + &topology.roles_observed, + &mut relative_paths, + &mut path_fingerprint_lineages, + &mut canonical_artifact_identities, + &payload_by_id, + )?; + prepared.push(normalized); + } + + if payload_by_id + .keys() + .any(|artifact_id| !manifest_artifact_ids.contains(*artifact_id)) + { + return Err(SccmServerIntakeError::UnexpectedPayload); + } + + prepared.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())); + + let mut artifacts = Vec::with_capacity(prepared.len()); + let mut evidence = Vec::new(); + let mut coverage_by_key = BTreeMap::::new(); + let mut request_keys = BTreeSet::new(); + let mut next_artifact_requests = Vec::new(); + let usable_source_keys = prepared + .iter() + .filter(|prepared_artifact| { + prepared_artifact.assessment.state == SccmCoverageState::Captured + }) + .map(|prepared_artifact| logical_source_key(&prepared_artifact.assessment)) + .collect::>(); + + for prepared_artifact in prepared { + let artifact = prepared_artifact.assessment; + let coverage_key = CoverageIdentityKey::from_artifact(&artifact); + coverage_by_key + .entry(coverage_key) + .and_modify(|row| row.artifact_ids.push(artifact.artifact_id.clone())) + .or_insert_with(|| SccmServerCoverage { + producer_role: artifact.producer_role.clone(), + producer_host_handle: artifact.producer_host_handle.clone(), + workflow_subject_role: artifact.workflow_subject_role.clone(), + workflow_subject_handle: artifact.workflow_subject_handle.clone(), + source_id: artifact.source_id.clone(), + state: artifact.state.clone(), + artifact_ids: vec![artifact.artifact_id.clone()], + }); + + let usable_compatible_candidate = + usable_source_keys.contains(&logical_source_key(&artifact)); + if let Some(request) = request_for_gap(&artifact, usable_compatible_candidate) { + let request_key = ( + request.logical_id.clone(), + role_sort_key(&request.role).to_owned(), + request.reason.clone(), + ); + if request_keys.insert(request_key) { + next_artifact_requests.push(request); + } + } + + evidence.extend(prepared_artifact.evidence); + artifacts.push(artifact); + } + + let mut coverage = coverage_by_key.into_values().collect::>(); + for row in &mut coverage { + row.artifact_ids.sort(); + } + evidence.sort_by(|left, right| { + ( + left.reference.artifact_id.as_str(), + left.reference.line_start, + left.reference.line_end, + left.reference.entry_id.as_str(), + ) + .cmp(&( + right.reference.artifact_id.as_str(), + right.reference.line_start, + right.reference.line_end, + right.reference.entry_id.as_str(), + )) + }); + next_artifact_requests.sort_by(|left, right| { + ( + left.logical_id.as_str(), + role_sort_key(&left.role), + left.reason.as_str(), + ) + .cmp(&( + right.logical_id.as_str(), + role_sort_key(&right.role), + right.reason.as_str(), + )) + }); + let schema_version = 1; + let intake_integrity = + canonical_intake_integrity(schema_version, &topology, &artifacts, &coverage, &evidence) + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + + Ok(SccmServerIntakeAssessment { + schema_version, + topology, + artifacts, + coverage, + evidence, + findings: Vec::new(), + next_artifact_requests, + intake_integrity, + extensions: normalize_opaque_extensions( + &manifest.extensions, + SccmServerIntakeError::MalformedManifest, + )?, + privacy_extensions: manifest + .privacy + .as_ref() + .map(|privacy| { + normalize_opaque_extensions( + &privacy.extensions, + SccmServerIntakeError::MalformedManifest, + ) + }) + .transpose()? + .unwrap_or_default(), + }) +} + +struct PreparedArtifact { + assessment: SccmServerArtifactAssessment, + evidence: Vec, +} + +impl PreparedArtifact { + fn sort_key(&self) -> (&str, &str, &str, &str, &str, &str, String, &str, &str) { + ( + role_sort_key(&self.assessment.producer_role), + self.assessment + .producer_host_handle + .as_deref() + .unwrap_or_default(), + self.assessment.source_id.as_str(), + self.assessment + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default(), + self.assessment + .workflow_subject_handle + .as_deref() + .unwrap_or_default(), + self.assessment.path_fingerprint.as_str(), + rotation_sort_key(self.assessment.rotation.as_ref()), + self.assessment + .original_basename + .as_deref() + .unwrap_or_default(), + self.assessment.artifact_id.as_str(), + ) + } +} + +fn validate_manifest_bounds( + manifest: &RawServerManifest, + payloads: &[SccmServerArtifactPayload], +) -> Result<(), SccmServerIntakeError> { + if manifest.artifacts.len() > MAX_SCCM_SERVER_MANIFEST_ARTIFACTS + || payloads.len() > MAX_SCCM_SERVER_MANIFEST_ARTIFACTS + { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + + let mut declared_bytes = 0u64; + let mut copied_bytes = 0u64; + for artifact in &manifest.artifacts { + if artifact.bytes_copied > MAX_SCCM_SERVER_ARTIFACT_BYTES { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + copied_bytes = copied_bytes + .checked_add(artifact.bytes_copied) + .ok_or(SccmServerIntakeError::ManifestLimitExceeded)?; + if copied_bytes > MAX_SCCM_SERVER_TOTAL_DECLARED_BYTES { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + if let Some(limit) = &artifact.collection_limit { + if limit.byte_limit > MAX_SCCM_SERVER_ARTIFACT_BYTES + || limit + .file_limit + .is_some_and(|value| { + value == 0 || value > MAX_SCCM_SERVER_MANIFEST_ARTIFACTS as u64 + }) + { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + declared_bytes = declared_bytes + .checked_add(limit.byte_limit) + .ok_or(SccmServerIntakeError::ManifestLimitExceeded)?; + if declared_bytes > MAX_SCCM_SERVER_TOTAL_DECLARED_BYTES { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + } + } + + let mut payload_bytes = 0u64; + for payload in payloads { + payload_bytes = payload_bytes + .checked_add( + u64::try_from(payload.bytes.len()) + .map_err(|_| SccmServerIntakeError::ManifestLimitExceeded)?, + ) + .ok_or(SccmServerIntakeError::ManifestLimitExceeded)?; + if payload_bytes > MAX_SCCM_SERVER_TOTAL_DECLARED_BYTES { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + } + + Ok(()) +} + +fn validate_manifest_metadata(manifest: &RawServerManifest) -> Result<(), SccmServerIntakeError> { + if manifest.synthetic_fixture { + let privacy = manifest + .privacy + .as_ref() + .ok_or(SccmServerIntakeError::MalformedManifest)?; + if manifest.proposal_only != Some(true) + || !privacy.synthetic + || privacy.raw_paths != "redacted" + { + return Err(SccmServerIntakeError::MalformedManifest); + } + } else if manifest.proposal_only == Some(true) + || manifest + .privacy + .as_ref() + .is_some_and(|privacy| privacy.synthetic || privacy.raw_paths != "redacted") + { + return Err(SccmServerIntakeError::MalformedManifest); + } + + if manifest.input_order_is_deliberately_unsorted == Some(false) { + return Err(SccmServerIntakeError::MalformedManifest); + } + Ok(()) +} + +fn normalize_topology( + manifest: &RawServerManifest, +) -> Result { + let site_handle = if manifest.synthetic_fixture { + // Manifest v1 synthetic fixtures use a closed, committed topology vocabulary. + // Expanding it requires an explicit fixture/profile review, not a caller-chosen label. + if manifest.topology.site_code != "LAB" + || !matches!( + manifest.topology.capture_host.as_str(), + "LAB-CM01" | "LAB-MP01" + ) + { + return Err(SccmServerIntakeError::InvalidTopology); + } + "synthetic:site:lab".to_owned() + } else if opaque_sha256_handle(&manifest.topology.site_code, "cmtraceopen.site.sha256.v1:") { + manifest.topology.site_code.clone() + } else { + return Err(SccmServerIntakeError::InvalidTopology); + }; + + let capture_host_handle = if manifest.synthetic_fixture { + format!( + "synthetic:host:{}", + manifest.topology.capture_host.to_ascii_lowercase() + ) + } else if opaque_sha256_handle( + &manifest.topology.capture_host, + "cmtraceopen.host.sha256.v1:", + ) { + manifest.topology.capture_host.clone() + } else { + return Err(SccmServerIntakeError::InvalidTopology); + }; + + if manifest.topology.roles_observed.is_empty() + || manifest.topology.roles_observed.iter().any(|role| { + !is_declared_server_role(role) + && (manifest.synthetic_fixture || !is_opaque_future_role(role)) + }) + { + return Err(SccmServerIntakeError::InvalidTopology); + } + let mut roles_observed = manifest.topology.roles_observed.clone(); + roles_observed.sort_by_key(|role| role_sort_key(role).to_owned()); + if roles_observed.windows(2).any(|roles| roles[0] == roles[1]) { + return Err(SccmServerIntakeError::InvalidTopology); + } + + let mut hierarchy_links = manifest + .topology + .hierarchy_links + .iter() + .map(|link| normalize_hierarchy_link(link, manifest.synthetic_fixture)) + .collect::, _>>()?; + hierarchy_links.sort(); + if hierarchy_links.windows(2).any(|links| links[0] == links[1]) { + return Err(SccmServerIntakeError::InvalidTopology); + } + + Ok(SccmServerTopologyAssessment { + capture_host_handle, + site_handle, + roles_observed, + hierarchy_links, + extensions: normalize_opaque_extensions( + &manifest.topology.extensions, + SccmServerIntakeError::InvalidTopology, + )?, + }) +} + +fn normalize_hierarchy_link( + link: &RawServerHierarchyLink, + synthetic_fixture: bool, +) -> Result { + let _ = RawServerHierarchyLink::KNOWN_FIELDS; + if !synthetic_fixture || !link.extensions.is_empty() { + return Err(SccmServerIntakeError::InvalidTopology); + } + let valid = link.origin_site_code == "LAB" + && link.origin_host_handle == "synthetic:host:site-01" + && matches!( + ( + link.target_site_code.as_str(), + link.target_host_handle.as_str() + ), + ("CHD", "synthetic:host:site-02") | ("SEC", "synthetic:host:site-03") + ); + if !valid { + return Err(SccmServerIntakeError::InvalidTopology); + } + Ok(SccmServerHierarchyLinkTopology { + origin_site_code: link.origin_site_code.clone(), + target_site_code: link.target_site_code.clone(), + origin_host_handle: link.origin_host_handle.clone(), + target_host_handle: link.target_host_handle.clone(), + }) +} + +fn normalize_artifact( + artifact: RawServerArtifact, + synthetic_fixture: bool, + roles_observed: &[SccmRole], + relative_paths: &mut BTreeSet, + path_fingerprint_lineages: &mut BTreeMap, + canonical_artifact_identities: &mut BTreeSet, + payload_by_id: &BTreeMap<&str, &[u8]>, +) -> Result { + let extensions = + normalize_opaque_extensions(&artifact.extensions, SccmServerIntakeError::InvalidArtifact)?; + let workflow_subject_extensions = artifact + .workflow_subject + .as_ref() + .map(|subject| { + normalize_opaque_extensions(&subject.extensions, SccmServerIntakeError::InvalidArtifact) + }) + .transpose()? + .unwrap_or_default(); + let configured_path_provenance_extensions = normalize_opaque_extensions( + &artifact.configured_path_provenance.extensions, + SccmServerIntakeError::InvalidArtifact, + )?; + let rotation_extensions = normalize_opaque_extensions( + &artifact.rotation.extensions, + SccmServerIntakeError::InvalidArtifact, + )?; + let collection_limit_extensions = artifact + .collection_limit + .as_ref() + .map(|limit| { + normalize_opaque_extensions(&limit.extensions, SccmServerIntakeError::InvalidArtifact) + }) + .transpose()? + .unwrap_or_default(); + let unclassified_producer = + artifact.producer_role == SccmRole::Unknown("unclassified".to_owned()); + let opaque_future_role = !synthetic_fixture && is_opaque_future_role(&artifact.producer_role); + let retained_unknown = opaque_future_role + || (unclassified_producer && artifact.capture_state == SccmCoverageState::Unsupported); + validate_artifact_annotations(&artifact, synthetic_fixture)?; + let source_version = + normalize_source_version(artifact.source_version.as_deref(), synthetic_fixture)?; + if !safe_manifest_artifact_id(&artifact.artifact_id, synthetic_fixture) + || !safe_source_id(&artifact.source_id, retained_unknown, synthetic_fixture) + || !safe_source_kind(&artifact.source_kind, retained_unknown, synthetic_fixture) + || !safe_lineage_id(&artifact.rotation.lineage_id, synthetic_fixture) + || !safe_path_fingerprint( + &artifact.configured_path_provenance.path_fingerprint, + synthetic_fixture, + ) + || !safe_original_path_marker(&artifact.original_path, synthetic_fixture) + || !safe_optional_handle( + artifact.producer_host_handle.as_deref(), + synthetic_fixture, + "host", + ) + || !safe_optional_handle( + artifact + .workflow_subject + .as_ref() + .and_then(|subject| subject.instance_handle.as_deref()), + synthetic_fixture, + "subject", + ) + || ((!retained_unknown || is_physical_artifact(&artifact)) + && artifact.producer_host_handle.is_none()) + || (retained_unknown + && !safe_public_basename(&artifact.original_basename, synthetic_fixture)) + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + + let producer_is_observed = roles_observed.contains(&artifact.producer_role); + if (!producer_is_observed && !unclassified_producer) + || (!is_declared_server_role(&artifact.producer_role) && !retained_unknown) + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + if artifact + .workflow_subject + .as_ref() + .is_some_and(|subject| !is_declared_server_role(&subject.role)) + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + + let workflow_subject_role = artifact + .workflow_subject + .as_ref() + .map(|subject| subject.role.clone()); + let classification = classify_declared_server_source( + &artifact.source_id, + &artifact.producer_role, + workflow_subject_role.as_ref(), + &artifact.source_kind, + &artifact.original_basename, + ); + + let (family, original_basename, rotation, mut parser_eligible) = + if let Some((spec, classified)) = classification { + validate_declared_source_tuple( + &artifact, + spec, + source_version.as_deref(), + synthetic_fixture, + roles_observed, + )?; + let family = + expected_family(spec.source_id).ok_or(SccmServerIntakeError::InvalidArtifact)?; + if let Some(classified) = classified { + let declared_rotation = parse_declared_rotation(&artifact.rotation)?; + if declared_rotation.as_ref() != Some(&classified.rotation) + || classified.family != family + || spec.source_kind != SccmServerSourceKind::CcmLog + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + ( + family, + Some(artifact.original_basename.clone()), + declared_rotation, + true, + ) + } else if spec.source_kind == SccmServerSourceKind::ProfileDefined + && is_physical_artifact(&artifact) + { + let declared_rotation = parse_declared_rotation(&artifact.rotation)? + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + if declared_rotation != SccmRotation::Current { + return Err(SccmServerIntakeError::InvalidArtifact); + } + ( + family, + Some(artifact.original_basename.clone()), + Some(declared_rotation), + false, + ) + } else { + let declared_rotation = parse_declared_rotation(&artifact.rotation)?; + if is_physical_artifact(&artifact) && declared_rotation.is_none() { + return Err(SccmServerIntakeError::InvalidArtifact); + } + ( + family, + Some(artifact.original_basename.clone()), + declared_rotation, + false, + ) + } + } else if retained_unknown { + ( + SccmArtifactFamily::Unknown(artifact.source_id.clone()), + Some(artifact.original_basename.clone()), + parse_retained_rotation(&artifact.rotation)?, + false, + ) + } else { + return Err(SccmServerIntakeError::InvalidArtifact); + }; + + let path_fingerprint_key = ( + role_sort_key(&artifact.producer_role).to_owned(), + artifact.producer_host_handle.clone(), + artifact.source_id.clone(), + workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + artifact + .workflow_subject + .as_ref() + .and_then(|subject| subject.instance_handle.clone()), + artifact + .configured_path_provenance + .path_fingerprint + .to_ascii_lowercase(), + ); + match path_fingerprint_lineages.get(&path_fingerprint_key) { + Some(lineage) if lineage != &artifact.rotation.lineage_id => { + return Err(SccmServerIntakeError::DuplicateArtifact); + } + Some(_) => {} + None => { + path_fingerprint_lineages + .insert(path_fingerprint_key, artifact.rotation.lineage_id.clone()); + } + } + + let canonical_identity = ( + role_sort_key(&artifact.producer_role).to_owned(), + artifact.producer_host_handle.clone(), + artifact.source_id.clone(), + workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + artifact + .workflow_subject + .as_ref() + .and_then(|subject| subject.instance_handle.clone()), + artifact + .configured_path_provenance + .path_fingerprint + .to_ascii_lowercase(), + artifact.rotation.lineage_id.clone(), + original_basename + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(), + rotation_sort_key(rotation.as_ref()), + ); + if !canonical_artifact_identities.insert(canonical_identity) { + return Err(SccmServerIntakeError::DuplicateArtifact); + } + + let configured_path_state = + parse_configured_path_state(&artifact.configured_path_provenance.state)?; + let configured_path_class = match artifact.configured_path_provenance.path_class.as_deref() { + None => None, + Some("nonDefault") => Some(SccmServerConfiguredPathClass::NonDefault), + Some(_) => return Err(SccmServerIntakeError::InvalidArtifact), + }; + if configured_path_class.is_some() + && configured_path_state != SccmServerConfiguredPathState::Configured + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + let collected_at_utc = normalize_collected_utc(&artifact.collected_utc)?; + let relative_path = validate_relative_path( + artifact.relative_path.clone(), + original_basename.as_deref(), + &artifact, + rotation.as_ref(), + relative_paths, + )?; + let (bytes, capture_provenance) = + validate_payload_contract(&artifact, relative_path.as_deref(), payload_by_id)?; + let collection_limit = artifact.collection_limit.as_ref().map(|limit| { + SccmServerCollectionLimit { + byte_limit: limit.byte_limit, + file_limit: limit.file_limit, + limit_applied: limit.limit_applied, + } + }); + let content_sha256 = bytes.map(payload_sha256); + let profile_eligible = source_version + .as_deref() + .is_some_and(|version| source_version_is_profile_eligible(version, synthetic_fixture)); + + let mut evidence = Vec::new(); + let mut state = artifact.capture_state.clone(); + if artifact.capture_state == SccmCoverageState::Captured && parser_eligible { + let bytes = bytes.ok_or(SccmServerIntakeError::MissingPayload)?; + let encoding = artifact + .encoding + .as_deref() + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + if let Some(content) = decode_server_payload(bytes, encoding)? { + let display_name = original_basename + .clone() + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + let (_, parse_errors) = + crate::parser::ccm::parse_content(&content, &display_name, None); + evidence = normalize_ccm_artifact( + SccmArtifact { + artifact_id: artifact.artifact_id.clone(), + display_name, + original_path: None, + host: artifact.producer_host_handle.clone(), + role: artifact.producer_role.clone(), + configmgr_version: source_version.clone(), + collected_at_utc: Some(collected_at_utc.clone()), + rotation: rotation + .clone() + .ok_or(SccmServerIntakeError::InvalidArtifact)?, + coverage: artifact.capture_state.clone(), + encoding: artifact.encoding.clone(), + }, + &content, + ); + let collected_utc_millis = DateTime::parse_from_rfc3339(&collected_at_utc) + .map_err(|_| SccmServerIntakeError::InvalidArtifact)? + .timestamp_millis(); + if evidence.iter().any(|record| { + record + .timestamp + .utc_millis + .is_some_and(|instant| instant > collected_utc_millis) + }) { + return Err(SccmServerIntakeError::InvalidArtifact); + } + if parse_errors > 0 + && (artifact.fragment_complete != Some(false) + || matches!( + family, + SccmArtifactFamily::Provider | SccmArtifactFamily::AdminService + )) + { + state = SccmCoverageState::ParseFailed; + } + } else { + state = SccmCoverageState::Unsupported; + parser_eligible = false; + } + } + + Ok(PreparedArtifact { + assessment: SccmServerArtifactAssessment { + artifact_id: artifact.artifact_id, + producer_role: artifact.producer_role, + producer_host_handle: artifact.producer_host_handle, + workflow_subject_role, + workflow_subject_handle: artifact + .workflow_subject + .and_then(|subject| subject.instance_handle), + source_id: artifact.source_id, + source_kind: artifact.source_kind, + family, + original_basename, + rotation, + rotation_lineage_handle: artifact.rotation.lineage_id, + state, + configured_path_state, + configured_path_class, + path_fingerprint: artifact.configured_path_provenance.path_fingerprint, + source_version, + profile_eligible, + collected_at_utc, + relative_path, + bytes_copied: artifact.bytes_copied, + content_sha256, + truncated: artifact.truncated, + fragment_complete: artifact.fragment_complete, + collection_limit, + capture_provenance, + parser_eligible, + extensions, + workflow_subject_extensions, + configured_path_provenance_extensions, + rotation_extensions, + collection_limit_extensions, + }, + evidence, + }) +} + +fn payload_sha256(bytes: &[u8]) -> String { + const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + encoded.push(char::from(LOWER_HEX[usize::from(byte >> 4)])); + encoded.push(char::from(LOWER_HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +fn checked_add_string_bytes(total: &mut usize, value: &str) -> Option<()> { + *total = total.checked_add(value.len())?; + Some(()) +} + +fn checked_add_optional_string_bytes(total: &mut usize, value: Option<&str>) -> Option<()> { + if let Some(value) = value { + checked_add_string_bytes(total, value)?; + } + Some(()) +} + +fn checked_add_opaque_extension_bytes( + total: &mut usize, + extensions: &[SccmServerOpaqueExtension], +) -> Option<()> { + for extension in extensions { + checked_add_string_bytes(total, &extension.name)?; + checked_add_string_bytes(total, &extension.value)?; + } + Some(()) +} + +fn artifact_family_integrity_key(family: &SccmArtifactFamily) -> &str { + family.serialized_name() +} + +fn topology_string_bytes(topology: &SccmServerTopologyAssessment) -> Option { + let mut total = 0usize; + checked_add_string_bytes(&mut total, &topology.capture_host_handle)?; + checked_add_string_bytes(&mut total, &topology.site_handle)?; + for role in &topology.roles_observed { + checked_add_string_bytes(&mut total, role_sort_key(role))?; + } + checked_add_opaque_extension_bytes(&mut total, &topology.extensions)?; + Some(total) +} + +fn artifact_string_bytes(artifacts: &[SccmServerArtifactAssessment]) -> Option { + let mut total = 0usize; + for artifact in artifacts { + checked_add_string_bytes(&mut total, &artifact.artifact_id)?; + checked_add_string_bytes(&mut total, role_sort_key(&artifact.producer_role))?; + checked_add_optional_string_bytes(&mut total, artifact.producer_host_handle.as_deref())?; + checked_add_optional_string_bytes( + &mut total, + artifact.workflow_subject_role.as_ref().map(role_sort_key), + )?; + checked_add_optional_string_bytes(&mut total, artifact.workflow_subject_handle.as_deref())?; + checked_add_string_bytes(&mut total, &artifact.source_id)?; + checked_add_string_bytes(&mut total, &artifact.source_kind)?; + checked_add_string_bytes(&mut total, artifact_family_integrity_key(&artifact.family))?; + checked_add_optional_string_bytes(&mut total, artifact.original_basename.as_deref())?; + match artifact.rotation.as_ref() { + None => {} + Some(SccmRotation::Current) => checked_add_string_bytes(&mut total, "current")?, + Some(SccmRotation::LoUnderscore) => { + checked_add_string_bytes(&mut total, "loUnderscore")?; + } + Some(SccmRotation::Numbered(_)) => { + checked_add_string_bytes(&mut total, "numbered")?; + } + Some(SccmRotation::Timestamped(value)) => { + checked_add_string_bytes(&mut total, "timestamped")?; + checked_add_string_bytes(&mut total, value)?; + } + // Canonical server intake admits only its closed rotation grammar. + // Reject a caller-injected open JSON value before traversing it. + Some(SccmRotation::Unknown(_)) => return None, + } + checked_add_string_bytes(&mut total, &artifact.rotation_lineage_handle)?; + checked_add_string_bytes(&mut total, coverage_sort_key(&artifact.state))?; + checked_add_string_bytes( + &mut total, + match artifact.configured_path_state { + SccmServerConfiguredPathState::Configured => "configured", + SccmServerConfiguredPathState::DefaultCandidate => "defaultCandidate", + SccmServerConfiguredPathState::NotRequested => "notRequested", + SccmServerConfiguredPathState::Supplied => "supplied", + }, + )?; + if artifact.configured_path_class.is_some() { + checked_add_string_bytes(&mut total, "nonDefault")?; + } + checked_add_string_bytes(&mut total, &artifact.path_fingerprint)?; + checked_add_optional_string_bytes(&mut total, artifact.source_version.as_deref())?; + checked_add_string_bytes(&mut total, &artifact.collected_at_utc)?; + checked_add_optional_string_bytes(&mut total, artifact.relative_path.as_deref())?; + checked_add_optional_string_bytes(&mut total, artifact.content_sha256.as_deref())?; + if let Some(provenance) = &artifact.capture_provenance { + checked_add_string_bytes(&mut total, &provenance.encoding)?; + } + checked_add_opaque_extension_bytes(&mut total, &artifact.extensions)?; + checked_add_opaque_extension_bytes(&mut total, &artifact.workflow_subject_extensions)?; + checked_add_opaque_extension_bytes( + &mut total, + &artifact.configured_path_provenance_extensions, + )?; + checked_add_opaque_extension_bytes(&mut total, &artifact.rotation_extensions)?; + checked_add_opaque_extension_bytes(&mut total, &artifact.collection_limit_extensions)?; + } + Some(total) +} + +fn coverage_artifact_memberships(coverage: &[SccmServerCoverage]) -> Option { + coverage.iter().try_fold(0usize, |total, record| { + total.checked_add(record.artifact_ids.len()) + }) +} + +fn coverage_string_bytes(coverage: &[SccmServerCoverage]) -> Option { + let mut total = 0usize; + for record in coverage { + checked_add_string_bytes(&mut total, role_sort_key(&record.producer_role))?; + checked_add_optional_string_bytes(&mut total, record.producer_host_handle.as_deref())?; + checked_add_optional_string_bytes( + &mut total, + record.workflow_subject_role.as_ref().map(role_sort_key), + )?; + checked_add_optional_string_bytes(&mut total, record.workflow_subject_handle.as_deref())?; + checked_add_string_bytes(&mut total, &record.source_id)?; + checked_add_string_bytes(&mut total, coverage_sort_key(&record.state))?; + for artifact_id in &record.artifact_ids { + checked_add_string_bytes(&mut total, artifact_id)?; + } + } + Some(total) +} + +fn evidence_string_bytes(evidence: &[SccmEvidence]) -> Option { + let mut total = 0usize; + for record in evidence { + checked_add_string_bytes(&mut total, &record.evidence_id)?; + checked_add_string_bytes(&mut total, &record.reference.artifact_id)?; + checked_add_string_bytes(&mut total, &record.reference.entry_id)?; + checked_add_string_bytes(&mut total, role_sort_key(&record.role))?; + checked_add_optional_string_bytes(&mut total, record.component.as_deref())?; + checked_add_optional_string_bytes(&mut total, record.ccm_source_file.as_deref())?; + checked_add_string_bytes(&mut total, &record.message)?; + checked_add_optional_string_bytes( + &mut total, + record.timestamp.original_display.as_deref(), + )?; + if let Some(context) = &record.execution_context { + checked_add_string_bytes(&mut total, &context.scheme)?; + checked_add_string_bytes(&mut total, &context.value)?; + } + } + Some(total) +} + +fn intake_integrity_structure( + topology: &SccmServerTopologyAssessment, + artifacts: &[SccmServerArtifactAssessment], + coverage: &[SccmServerCoverage], + evidence: &[SccmEvidence], + coverage_artifact_memberships: usize, +) -> Option { + Some(IntakeIntegrityStructure { + topology_string_bytes: topology_string_bytes(topology)?, + artifact_string_bytes: artifact_string_bytes(artifacts)?, + coverage_string_bytes: coverage_string_bytes(coverage)?, + evidence_string_bytes: evidence_string_bytes(evidence)?, + coverage_artifact_memberships, + }) +} + +fn canonical_intake_integrity( + schema_version: u32, + topology: &SccmServerTopologyAssessment, + artifacts: &[SccmServerArtifactAssessment], + coverage: &[SccmServerCoverage], + evidence: &[SccmEvidence], +) -> Option { + let coverage_artifact_memberships = coverage_artifact_memberships(coverage)?; + let structure = intake_integrity_structure( + topology, + artifacts, + coverage, + evidence, + coverage_artifact_memberships, + )?; + canonical_intake_integrity_with_structure( + schema_version, + topology, + artifacts, + coverage, + evidence, + structure, + None, + ) +} + +fn canonical_intake_integrity_for_adapter( + schema_version: u32, + topology: &SccmServerTopologyAssessment, + artifacts: &[SccmServerArtifactAssessment], + coverage: &[SccmServerCoverage], + evidence: &[SccmEvidence], + expected: &SccmServerIntakeIntegrity, +) -> Option { + let coverage_artifact_memberships = coverage_artifact_memberships(coverage)?; + if coverage_artifact_memberships != expected.structure.coverage_artifact_memberships { + return None; + } + let structure = intake_integrity_structure( + topology, + artifacts, + coverage, + evidence, + coverage_artifact_memberships, + )?; + if structure != expected.structure { + return None; + } + canonical_intake_integrity_with_structure( + schema_version, + topology, + artifacts, + coverage, + evidence, + structure, + Some(expected), + ) +} + +#[allow(clippy::too_many_arguments)] +fn canonical_intake_integrity_with_structure( + schema_version: u32, + topology: &SccmServerTopologyAssessment, + artifacts: &[SccmServerArtifactAssessment], + coverage: &[SccmServerCoverage], + evidence: &[SccmEvidence], + structure: IntakeIntegrityStructure, + expected: Option<&SccmServerIntakeIntegrity>, +) -> Option { + #[cfg(test)] + INTAKE_CANONICALIZATION_CALLS.with(|calls| calls.set(calls.get().saturating_add(1))); + + let normalized_topology = normalized_topology_or_none(topology)?; + + let mut normalized_coverage = coverage.to_vec(); + for record in &mut normalized_coverage { + record.artifact_ids.sort(); + if record.artifact_ids.windows(2).any(|ids| ids[0] == ids[1]) { + return None; + } + } + + if evidence + .iter() + .any(|record| record.evidence_id != record.reference.entry_id) + { + return None; + } + + let mut artifact_integrity = BTreeMap::new(); + for artifact in artifacts { + let max_payload_len = match expected { + Some(expected) => Some( + expected + .artifacts + .get(artifact.artifact_id.as_str())? + .payload_len, + ), + None => None, + }; + if artifact_integrity + .insert( + ArtifactIntegrityIdentity(artifact.artifact_id.clone()), + canonical_record_digest_bounded(b"artifact", artifact, max_payload_len)?, + ) + .is_some() + { + return None; + } + } + + let mut coverage_integrity = BTreeMap::new(); + for record in &normalized_coverage { + let identity = CoverageIdentityKey::from_coverage(record); + let max_payload_len = match expected { + Some(expected) => Some(expected.coverage.get(&identity)?.payload_len), + None => None, + }; + if coverage_integrity + .insert( + identity, + canonical_record_digest_bounded(b"coverage", record, max_payload_len)?, + ) + .is_some() + { + return None; + } + } + + let mut evidence_integrity = BTreeMap::new(); + for record in evidence { + let max_payload_len = match expected { + Some(expected) => Some( + expected + .evidence + .get(record.evidence_id.as_str())? + .payload_len, + ), + None => None, + }; + if evidence_integrity + .insert( + EvidenceIntegrityIdentity(record.evidence_id.clone()), + canonical_record_digest_bounded(b"evidence", record, max_payload_len)?, + ) + .is_some() + { + return None; + } + } + + Some(SccmServerIntakeIntegrity { + schema_version, + topology_role_count: normalized_topology.roles_observed.len(), + structure, + topology: canonical_record_digest_bounded( + b"topology", + &normalized_topology, + expected.map(|expected| expected.topology.payload_len), + )?, + artifacts: artifact_integrity, + coverage: coverage_integrity, + evidence: evidence_integrity, + }) +} + +const INTAKE_INTEGRITY_DOMAIN: &[u8] = b"cmtraceopen.sccm.server-intake.integrity.v1"; + +#[cfg(test)] +std::thread_local! { + static INTAKE_CANONICALIZATION_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static INTAKE_CANONICAL_JSON_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_intake_integrity_work_probe() { + INTAKE_CANONICALIZATION_CALLS.with(|calls| calls.set(0)); + INTAKE_CANONICAL_JSON_BYTES.with(|bytes| bytes.set(0)); +} + +#[cfg(test)] +pub(crate) fn intake_integrity_work_probe() -> (usize, usize) { + let calls = INTAKE_CANONICALIZATION_CALLS.with(std::cell::Cell::get); + let bytes = INTAKE_CANONICAL_JSON_BYTES.with(std::cell::Cell::get); + (calls, bytes) +} + +struct IntakeIntegrityWriter { + hasher: Sha256, + payload_len: u64, + max_payload_len: Option, +} + +impl Write for IntakeIntegrityWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + #[cfg(test)] + INTAKE_CANONICAL_JSON_BYTES.with(|total| { + total.set(total.get().saturating_add(bytes.len())); + }); + let bytes_len = u64::try_from(bytes.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "integrity input length overflow", + ) + })?; + let payload_len = self.payload_len.checked_add(bytes_len).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "integrity input length overflow", + ) + })?; + if self + .max_payload_len + .is_some_and(|max_payload_len| payload_len > max_payload_len) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "integrity input exceeds sealed payload length", + )); + } + self.hasher.update(bytes); + self.payload_len = payload_len; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn framed_integrity_hasher(domain: &[u8]) -> Option { + // The master namespace is fixed, the variable domain is length-framed, + // and the one canonical JSON value is terminal. That encoding has only + // one boundary interpretation, so hashing does not need a length pass. + let domain_len = u64::try_from(domain.len()).ok()?; + let mut hasher = Sha256::new(); + hasher.update(INTAKE_INTEGRITY_DOMAIN); + hasher.update(domain_len.to_be_bytes()); + hasher.update(domain); + Some(hasher) +} + +#[cfg(test)] +fn canonical_record_digest( + domain: &[u8], + record: &T, +) -> Option { + canonical_record_digest_bounded(domain, record, None) +} + +fn canonical_record_digest_bounded( + domain: &[u8], + record: &T, + max_payload_len: Option, +) -> Option { + let mut writer = IntakeIntegrityWriter { + hasher: framed_integrity_hasher(domain)?, + payload_len: 0, + max_payload_len, + }; + serde_json::to_writer(&mut writer, record).ok()?; + if max_payload_len.is_some_and(|max_payload_len| writer.payload_len != max_payload_len) { + return None; + } + let digest = writer.hasher.finalize(); + let mut encoded = [0; 32]; + encoded.copy_from_slice(&digest); + Some(IntakeIntegrityRecord { + payload_len: writer.payload_len, + digest: encoded, + }) +} + +#[cfg(test)] +mod intake_integrity_tests { + use std::fs; + use std::path::Path; + + use super::*; + + fn canonical_assessment() -> SccmServerIntakeAssessment { + let directory = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/management-point/canonical-intake-policy-scope"); + let manifest_json = + fs::read_to_string(directory.join("manifest.json")).expect("fixture manifest"); + let manifest: Value = serde_json::from_str(&manifest_json).expect("fixture JSON"); + let payloads = manifest["artifacts"] + .as_array() + .expect("fixture artifacts") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"] + .as_str() + .expect("fixture artifact ID") + .to_owned(), + bytes: fs::read(directory.join(relative_path)).expect("fixture payload"), + }) + }) + .collect::>(); + assess_server_intake(&manifest_json, &payloads).expect("canonical fixture") + } + + fn integrity(assessment: &SccmServerIntakeAssessment) -> Option { + canonical_intake_integrity( + assessment.schema_version, + &assessment.topology, + &assessment.artifacts, + &assessment.coverage, + &assessment.evidence, + ) + } + + #[test] + fn intake_integrity_is_compact_and_collision_safe() { + let assessment = canonical_assessment(); + let baseline = integrity(&assessment).expect("baseline integrity"); + + let mut large_message = assessment.clone(); + large_message.evidence[0].message = "x".repeat(1024 * 1024); + let large_integrity = integrity(&large_message).expect("large-message integrity"); + assert!( + large_integrity.retained_material_bytes() <= 1_024, + "integrity retained {} bytes for one 1 MiB message", + large_integrity.retained_material_bytes() + ); + + let mut schema_mutation = assessment.clone(); + schema_mutation.schema_version += 1; + assert_ne!(integrity(&schema_mutation), Some(baseline.clone())); + + let mut topology_mutation = assessment.clone(); + topology_mutation + .topology + .capture_host_handle + .push_str("-changed"); + assert_ne!(integrity(&topology_mutation), Some(baseline.clone())); + + let mut artifact_mutation = assessment.clone(); + artifact_mutation.artifacts[0] + .path_fingerprint + .push_str("-changed"); + assert_ne!(integrity(&artifact_mutation), Some(baseline.clone())); + + let mut coverage_mutation = assessment.clone(); + coverage_mutation.coverage[0].state = SccmCoverageState::Capped; + assert_ne!(integrity(&coverage_mutation), Some(baseline.clone())); + + let mut evidence_mutation = assessment.clone(); + evidence_mutation.evidence[0].message.push_str(" changed"); + assert_ne!(integrity(&evidence_mutation), Some(baseline.clone())); + + let mut duplicate = assessment.clone(); + duplicate.evidence.push(duplicate.evidence[0].clone()); + assert_eq!(integrity(&duplicate), None); + + let mut removed = assessment.clone(); + removed.evidence.clear(); + assert_ne!(integrity(&removed), Some(baseline.clone())); + + let mut appended = assessment.clone(); + let mut appended_record = appended.evidence[0].clone(); + appended_record.evidence_id = "mp-policy-current:replayed".to_owned(); + appended_record.reference.entry_id = "mp-policy-current:replayed".to_owned(); + appended.evidence.push(appended_record); + assert_ne!(integrity(&appended), Some(baseline.clone())); + + let mut collision = assessment.clone(); + let mut colliding_record = collision.evidence[0].clone(); + colliding_record.message.push_str(" different content"); + collision.evidence.push(colliding_record); + assert_eq!( + integrity(&collision), + None, + "one semantic evidence identity cannot retain two bodies" + ); + + let mut artifact_collision = assessment.clone(); + let mut colliding_artifact = artifact_collision.artifacts[0].clone(); + colliding_artifact.path_fingerprint.push_str("-different"); + artifact_collision.artifacts.push(colliding_artifact); + assert_eq!(integrity(&artifact_collision), None); + + let mut coverage_collision = assessment.clone(); + let mut colliding_coverage = coverage_collision.coverage[0].clone(); + colliding_coverage.artifact_ids = vec!["different-artifact".to_owned()]; + coverage_collision.coverage.push(colliding_coverage); + assert_eq!(integrity(&coverage_collision), None); + + let mut duplicate_membership = assessment.clone(); + let artifact_id = duplicate_membership.coverage[0].artifact_ids[0].clone(); + duplicate_membership.coverage[0] + .artifact_ids + .push(artifact_id); + assert_eq!(integrity(&duplicate_membership), None); + + let mut duplicate_topology_role = assessment; + duplicate_topology_role + .topology + .roles_observed + .push(SccmRole::ManagementPoint); + assert_eq!(integrity(&duplicate_topology_role), None); + } + + #[test] + fn intake_integrity_hash_framing_separates_domains_and_boundaries() { + fn framed_digest(domain: &[u8], payload: &[u8]) -> IntakeIntegrityDigest { + let mut hasher = framed_integrity_hasher(domain).expect("test hash framing"); + hasher.update(payload); + let digest = hasher.finalize(); + let mut encoded = [0; 32]; + encoded.copy_from_slice(&digest); + encoded + } + + assert_ne!( + framed_digest(b"artifact", b"same-body"), + framed_digest(b"evidence", b"same-body"), + "record domains must not share a digest namespace" + ); + assert_ne!( + framed_digest(b"a", b"bc"), + framed_digest(b"ab", b"c"), + "a length-framed domain and terminal payload must not concatenate ambiguously" + ); + } + + #[test] + fn intake_integrity_serializes_each_record_once() { + struct CountedRecord<'a>(&'a std::cell::Cell); + + impl Serialize for CountedRecord<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.set(self.0.get().saturating_add(1)); + serializer.serialize_str("canonical-payload") + } + } + + let serializations = std::cell::Cell::new(0); + canonical_record_digest(b"evidence", &CountedRecord(&serializations)) + .expect("integrity digest"); + assert_eq!( + serializations.get(), + 1, + "integrity hashing must stream one terminal canonical JSON payload" + ); + } +} + +fn validate_payload_contract<'a>( + artifact: &RawServerArtifact, + relative_path: Option<&str>, + payload_by_id: &'a BTreeMap<&str, &'a [u8]>, +) -> Result<(Option<&'a [u8]>, Option), SccmServerIntakeError> { + let payload = payload_by_id.get(artifact.artifact_id.as_str()).copied(); + if is_physical_artifact(artifact) { + let payload = payload.ok_or(SccmServerIntakeError::MissingPayload)?; + if relative_path.is_none() { + return Err(SccmServerIntakeError::InvalidArtifact); + } + if payload.len() as u64 != artifact.bytes_copied { + return Err(SccmServerIntakeError::PayloadLengthMismatch); + } + let limit = artifact + .collection_limit + .as_ref() + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + let valid_limit = match artifact.capture_state { + SccmCoverageState::Captured => { + !limit.limit_applied && artifact.bytes_copied <= limit.byte_limit + } + SccmCoverageState::Capped => { + limit.limit_applied + && artifact.bytes_copied == limit.byte_limit + && artifact.bytes_copied > 0 + } + SccmCoverageState::ParseFailed => { + if limit.limit_applied { + artifact.bytes_copied == limit.byte_limit && artifact.bytes_copied > 0 + } else { + artifact.bytes_copied <= limit.byte_limit + } + } + _ => false, + }; + let encoding = artifact + .encoding + .as_deref() + .filter(|encoding| safe_encoding(encoding)) + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + if !valid_limit || limit.byte_limit == 0 { + return Err(SccmServerIntakeError::InvalidArtifact); + } + return Ok(( + Some(payload), + Some(SccmServerCaptureProvenance { + schema_version: 1, + encoding: encoding.to_owned(), + byte_limit: limit.byte_limit, + file_limit: limit.file_limit, + limit_applied: limit.limit_applied, + }), + )); + } + + if payload.is_some() + || relative_path.is_some() + || artifact.bytes_copied != 0 + || artifact.encoding.is_some() + { + return Err(SccmServerIntakeError::UnexpectedPayload); + } + match artifact.capture_state { + SccmCoverageState::Captured => { + return Err(SccmServerIntakeError::MissingPayload); + } + SccmCoverageState::Capped => { + let limit = artifact + .collection_limit + .as_ref() + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + if !limit.limit_applied || limit.byte_limit == 0 { + return Err(SccmServerIntakeError::InvalidArtifact); + } + } + SccmCoverageState::ParseFailed => { + if artifact.collection_limit.as_ref().is_some_and(|limit| { + limit.byte_limit == 0 || limit.limit_applied + }) { + return Err(SccmServerIntakeError::InvalidArtifact); + } + } + _ if artifact.collection_limit.is_some() => { + return Err(SccmServerIntakeError::UnexpectedPayload); + } + _ => {} + } + Ok((None, None)) +} + +fn decode_server_payload( + bytes: &[u8], + encoding: &str, +) -> Result, SccmServerIntakeError> { + match encoding { + "utf-8" => { + let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes); + std::str::from_utf8(bytes) + .map(|content| Some(content.to_owned())) + .map_err(|_| SccmServerIntakeError::InvalidPayloadEncoding) + } + "utf-16le" => { + let bytes = bytes.strip_prefix(&[0xff, 0xfe]).unwrap_or(bytes); + if !bytes.len().is_multiple_of(2) { + return Err(SccmServerIntakeError::InvalidPayloadEncoding); + } + let units = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + String::from_utf16(&units) + .map(Some) + .map_err(|_| SccmServerIntakeError::InvalidPayloadEncoding) + } + "windows-1252" => Ok(Some( + encoding_rs::WINDOWS_1252 + .decode_without_bom_handling(bytes) + .0 + .into_owned(), + )), + "unknown" => Ok(None), + _ => Err(SccmServerIntakeError::InvalidPayloadEncoding), + } +} + +fn validate_relative_path( + relative_path: Option, + original_basename: Option<&str>, + artifact: &RawServerArtifact, + rotation: Option<&SccmRotation>, + relative_paths: &mut BTreeSet, +) -> Result, SccmServerIntakeError> { + if !is_physical_artifact(artifact) { + if relative_path.is_some() { + return Err(SccmServerIntakeError::InvalidArtifact); + } + return Ok(None); + } + let relative_path = relative_path.ok_or(SccmServerIntakeError::InvalidArtifact)?; + let components = relative_path.split('/').collect::>(); + let expected_role = + role_path_segment(&artifact.producer_role).ok_or(SccmServerIntakeError::InvalidArtifact)?; + let expected_source = source_path_segment(&artifact.source_id); + let expected_rotation = + rotation_path_segment(rotation).ok_or(SccmServerIntakeError::InvalidArtifact)?; + let basename = original_basename.ok_or(SccmServerIntakeError::InvalidArtifact)?; + let expected_basename = basename_path_segment(basename); + let mut cursor = 0; + let fixed_prefix = [ + "evidence", + "sccm", + "server", + expected_role.as_str(), + expected_source.as_str(), + ]; + if components.get(..fixed_prefix.len()) != Some(fixed_prefix.as_slice()) { + return Err(SccmServerIntakeError::InvalidArtifact); + } + cursor += fixed_prefix.len(); + + if let Some(subject_role) = artifact + .workflow_subject + .as_ref() + .map(|subject| &subject.role) + { + let subject_segment = role_path_segment(subject_role) + .map(|role| format!("subject-{role}")) + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + if components.get(cursor).copied() != Some(subject_segment.as_str()) { + return Err(SccmServerIntakeError::InvalidArtifact); + } + cursor += 1; + } + if artifact.workflow_subject.is_some() + && components + .get(cursor) + .is_some_and(|component| opaque_path_component(component, "instance-")) + { + cursor += 1; + } + if components + .get(cursor) + .is_some_and(|component| opaque_path_component(component, "root-")) + { + cursor += 1; + } + + if components.get(cursor).copied() != Some(expected_rotation.as_str()) + || components.get(cursor + 1).copied() != Some(expected_basename.as_str()) + || components.len() != cursor + 2 + || relative_path.contains('\\') + || relative_path.split('/').any(|segment| { + segment.is_empty() + || segment == "." + || segment == ".." + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }) + || !relative_paths.insert(relative_path.to_ascii_lowercase()) + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + Ok(Some(relative_path)) +} + +fn is_physical_artifact(artifact: &RawServerArtifact) -> bool { + artifact.relative_path.is_some() + || artifact.bytes_copied > 0 + || artifact.encoding.is_some() +} + +fn safe_encoding(encoding: &str) -> bool { + matches!(encoding, "utf-8" | "utf-16le" | "windows-1252" | "unknown") +} + +fn role_path_segment(role: &SccmRole) -> Option { + Some(match role { + SccmRole::SiteServer => "site-server".to_owned(), + SccmRole::ManagementPoint => "management-point".to_owned(), + SccmRole::DistributionPoint => "distribution-point".to_owned(), + SccmRole::SoftwareUpdatePoint => "software-update-point".to_owned(), + SccmRole::WsUs => "wsus".to_owned(), + SccmRole::Provider => "provider".to_owned(), + SccmRole::AdminService => "admin-service".to_owned(), + SccmRole::Unknown(value) => format!( + "role-{}", + opaque_sha256_digest(value, "cmtraceopen.role.sha256.v1:")? + ), + SccmRole::Client => return None, + }) +} + +fn source_path_segment(source_id: &str) -> String { + opaque_sha256_digest(source_id, "cmtraceopen.source.sha256.v1:") + .map(|digest| format!("source-{digest}")) + .unwrap_or_else(|| source_id.to_owned()) +} + +fn basename_path_segment(basename: &str) -> String { + opaque_sha256_digest(basename, "cmtraceopen.basename.sha256.v1:") + .map(|digest| format!("basename-{digest}")) + .unwrap_or_else(|| basename.to_owned()) +} + +fn rotation_path_segment(rotation: Option<&SccmRotation>) -> Option { + Some(match rotation? { + SccmRotation::Current => "current".to_owned(), + SccmRotation::LoUnderscore => "lo_".to_owned(), + SccmRotation::Numbered(value) => format!("numbered-{value}"), + SccmRotation::Timestamped(value) => format!("timestamped-{value}"), + SccmRotation::Unknown(_) => return None, + }) +} + +fn opaque_path_component(component: &str, prefix: &str) -> bool { + component.strip_prefix(prefix).is_some_and(|value| { + (8..=64).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +fn parse_declared_rotation( + rotation: &RawServerRotation, +) -> Result, SccmServerIntakeError> { + let parsed = match rotation.kind.as_str() { + "current" if rotation.value.is_none() => SccmRotation::Current, + "lo_" if rotation.value.is_none() => SccmRotation::LoUnderscore, + "numbered" => { + let number = rotation + .value + .as_ref() + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + SccmRotation::Numbered(number) + } + "timestamped" => { + let timestamp = rotation + .value + .as_ref() + .and_then(Value::as_str) + .filter(|value| is_canonical_rotation_timestamp(value)) + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + SccmRotation::Timestamped(timestamp.to_owned()) + } + _ => return Ok(None), + }; + Ok(Some(parsed)) +} + +fn parse_retained_rotation( + rotation: &RawServerRotation, +) -> Result, SccmServerIntakeError> { + if rotation.kind == "none" && rotation.value.is_none() { + return Ok(None); + } + parse_declared_rotation(rotation)? + .ok_or(SccmServerIntakeError::InvalidArtifact) + .map(Some) +} + +fn parse_configured_path_state( + value: &str, +) -> Result { + match value { + "configured" => Ok(SccmServerConfiguredPathState::Configured), + "defaultCandidate" => Ok(SccmServerConfiguredPathState::DefaultCandidate), + "notRequested" => Ok(SccmServerConfiguredPathState::NotRequested), + "supplied" => Ok(SccmServerConfiguredPathState::Supplied), + _ => Err(SccmServerIntakeError::InvalidArtifact), + } +} + +fn normalize_collected_utc(value: &str) -> Result { + let parsed = + DateTime::parse_from_rfc3339(value).map_err(|_| SccmServerIntakeError::InvalidArtifact)?; + Ok(parsed + .with_timezone(&Utc) + .to_rfc3339_opts(SecondsFormat::Secs, true)) +} + +fn logical_source_key( + artifact: &SccmServerArtifactAssessment, +) -> (String, String, String, String, String) { + ( + role_sort_key(&artifact.producer_role).to_owned(), + artifact + .producer_host_handle + .as_deref() + .unwrap_or_default() + .to_owned(), + artifact.source_id.clone(), + artifact + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + artifact + .workflow_subject_handle + .as_deref() + .unwrap_or_default() + .to_owned(), + ) +} + +fn request_for_gap( + artifact: &SccmServerArtifactAssessment, + usable_compatible_candidate: bool, +) -> Option { + if artifact.state == SccmCoverageState::Absent + && artifact.configured_path_state == SccmServerConfiguredPathState::DefaultCandidate + && usable_compatible_candidate + { + return None; + } + if !matches!( + artifact.state, + SccmCoverageState::Absent + | SccmCoverageState::AccessDenied + | SccmCoverageState::Capped + | SccmCoverageState::Unsupported + | SccmCoverageState::ParseFailed + ) { + return None; + } + + let classified = classify_artifact_name( + artifact.original_basename.as_deref()?, + artifact.producer_role.clone(), + ); + if !classified.uses_ccm_records + || !classified.supported_for_diagnosis + || classified.family != artifact.family + { + return None; + } + + Some(SccmArtifactRequest { + logical_id: classified.logical_name, + role: classified.role, + reason: format!("Collect the complete {} file.", classified.basename), + }) +} + +fn normalize_source_version( + value: Option<&str>, + synthetic_fixture: bool, +) -> Result, SccmServerIntakeError> { + let Some(value) = value else { + return Ok(None); + }; + let safe = if synthetic_fixture { + matches!(value, "5.00.TEST" | "5.00.TEST.0001" | "5.00.TEST.0002") + } else { + source_version_is_profile_eligible(value, false) + || opaque_sha256_handle(value, "cmtraceopen.version.sha256.v1:") + }; + if !safe { + return Err(SccmServerIntakeError::InvalidArtifact); + } + Ok(Some(value.to_owned())) +} + +fn validate_declared_source_tuple( + artifact: &RawServerArtifact, + spec: &SccmServerSourceSpec, + source_version: Option<&str>, + synthetic_fixture: bool, + roles_observed: &[SccmRole], +) -> Result<(), SccmServerIntakeError> { + if spec.source_id != "server-sup-wsus" { + return Ok(()); + } + + let subject = artifact + .workflow_subject + .as_ref() + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + if spec.source_kind != SccmServerSourceKind::ProfileDefined + || artifact.producer_role != SccmRole::WsUs + || subject.role != SccmRole::SoftwareUpdatePoint + || !roles_observed.contains(&SccmRole::WsUs) + || !roles_observed.contains(&SccmRole::SoftwareUpdatePoint) + || artifact.producer_host_handle.is_none() + || subject.instance_handle.is_none() + || source_version.is_none() + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + + if synthetic_fixture { + let rotation_is_bounded = if is_physical_artifact(artifact) { + artifact.rotation.kind == "current" + } else { + artifact.rotation.kind == "providerDefined" + } && artifact.rotation.value.is_none(); + if artifact.producer_host_handle.as_deref() != Some("synthetic:host:wsus-01") + || !matches!( + subject.instance_handle.as_deref(), + Some("synthetic:subject:sup-01" | "safe:sup:lab-sup-01") + ) + || source_version != Some("5.00.TEST") + || artifact.configured_path_provenance.path_fingerprint + != "synthetic:path:sup-wsus-health" + || !rotation_is_bounded + || artifact.rotation.lineage_id != "sup-wsus-health" + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + } + + Ok(()) +} + +fn validate_artifact_annotations( + artifact: &RawServerArtifact, + synthetic_fixture: bool, +) -> Result<(), SccmServerIntakeError> { + if artifact + .workflow_subject + .as_ref() + .and_then(|subject| subject.basis.as_deref()) + .is_some_and(|basis| { + if synthetic_fixture { + basis != "incidentScopeOnly" + } else { + !opaque_sha256_handle(basis, "cmtraceopen.subject-basis.sha256.v1:") + } + }) + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + if artifact + .default_candidate_state + .as_deref() + .is_some_and(|value| value != "absentCandidateOnly") + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + + for (detail, expected_state, synthetic_value, domain) in [ + ( + artifact.collection_detail.as_deref(), + SccmCoverageState::AccessDenied, + "synthetic permission denial", + "collection-detail", + ), + ( + artifact.skip_reason.as_deref(), + SccmCoverageState::Skipped, + "optional supplemental source not requested", + "skip-reason", + ), + ( + artifact.unsupported_reason.as_deref(), + SccmCoverageState::Unsupported, + "no approved server source contract", + "unsupported-reason", + ), + ] { + if let Some(detail) = detail { + if artifact.capture_state != expected_state + || if synthetic_fixture { + detail != synthetic_value + } else { + !opaque_sha256_handle(detail, &format!("cmtraceopen.{domain}.sha256.v1:")) + } + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + } + } + + match (artifact.truncated, artifact.fragment_complete) { + (None, None) => {} + (Some(false), Some(false)) if artifact.capture_state == SccmCoverageState::Captured => {} + (None, Some(false)) if artifact.capture_state == SccmCoverageState::Captured => {} + (Some(true), Some(false)) if artifact.capture_state == SccmCoverageState::Capped => {} + _ => return Err(SccmServerIntakeError::InvalidArtifact), + } + Ok(()) +} + +fn source_version_is_profile_eligible(value: &str, synthetic_fixture: bool) -> bool { + if synthetic_fixture && matches!(value, "5.00.TEST" | "5.00.TEST.0001" | "5.00.TEST.0002") { + return true; + } + let mut parts = value.split('.'); + matches!( + ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ), + (Some("5"), Some("00"), Some(build), Some(revision), None) + if build.len() == 4 + && revision.len() == 4 + && build.bytes().all(|byte| byte.is_ascii_digit()) + && revision.bytes().all(|byte| byte.is_ascii_digit()) + ) +} + +fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { + if synthetic_fixture { + // The top-level manifest version gate makes this the v1 synthetic-fixture vocabulary. + // These public identities must never become free-form based on the manifest flag alone. + return matches!( + value, + "a-mp-policy" + | "b-sitecomp" + | "dp-dist-current" + | "dp-distribution-absent-candidate" + | "dp-absent-01-distmgr" + | "dp-absent-02-provider" + | "dp-distribution-failure-01-distmgr" + | "dp-healthy-01-distmgr" + | "dp-healthy-02-pkgxfer" + | "dp-healthy-03-provider" + | "dp-incomplete-01-distmgr" + | "dp-incomplete-02-pkgxfer-denied" + | "dp-incomplete-03-provider-absent" + | "dp-malformed-01-provider" + | "dp-rotation-01-current-fragment" + | "dp-rotation-02-lo-fragment" + | "dp-rotation-03-malformed" + | "dp-serve-01-distmgr" + | "dp-serve-02-pkgxfer" + | "dp-serve-03-provider" + | "dp-serve-04-status" + | "dp-transfer-retry-01-distmgr" + | "dp-transfer-retry-02-pkgxfer" + | "dp-transfer-failure-01-distmgr" + | "dp-transfer-failure-02-pkgxfer" + | "dp-backlog-blocked-01-distmgr" + | "dp-backlog-blocked-02-pkgxfer" + | "dp-transfer-deferred-01-distmgr" + | "dp-transfer-deferred-02-pkgxfer" + | "dp-recovery-01-distmgr" + | "dp-recovery-02-pkgxfer" + | "dp-recovery-03-provider" + | "dp-validation-failure-01-distmgr" + | "dp-validation-failure-02-pkgxfer" + | "dp-validation-failure-03-provider" + | "dp-version-01-distmgr" + | "dp-version-02-pkgxfer" + | "dp-version-03-provider" + | "dp-version-04-provider-dp02" + | "dp-version-05-distmgr-dp02" + | "dp-version-06-pkgxfer-dp02" + | "mp-iis-skipped" + | "mp-policy-access-denied" + | "mp-policy-configured" + | "mp-policy-current" + | "mp-policy-lo" + | "mp-policy-multiline" + | "mp-policy-numbered-2" + | "mp-policy-root-a-current" + | "mp-policy-root-b-current" + | "mp-policy-ts-20260729-235700" + | "incomplete-01-wcm" + | "incomplete-02-wsync-denied" + | "incomplete-03-wsus-absent" + | "metadata-failure-01-wcm" + | "metadata-failure-02-wsync" + | "rotation-01-current" + | "rotation-02-lo" + | "rotation-03-malformed" + | "sitecomp-current" + | "sup-setup-failure-01-setup" + | "sup-sync-capped" + | "sup-sync-current" + | "sup-wsus-health-skipped" + | "supplemental-01-wcm" + | "supplemental-02-wsync" + | "supplemental-03-wsus" + | "supplemental-04-wsus-health" + | "sync-retry-01-wcm" + | "sync-retry-02-wsync" + | "sync-success-01-wcm" + | "sync-success-02-wsync" + | "sync-success-03-wsus" + | "admin-auth-current" + | "admin-backend-current" + | "admin-iis-current" + | "admin-success-current" + | "blocked-deferred-admin-current" + | "contradictory-provider-current" + | "coverage-admin-access-denied" + | "coverage-admin-parse-failed" + | "coverage-admin-skipped" + | "coverage-provider-absent" + | "coverage-provider-capped" + | "coverage-provider-unsupported" + | "iis-supplemental-current" + | "incomplete-admin-current" + | "privacy-admin-current" + | "privacy-provider-current" + | "provider-authz-current" + | "provider-query-current" + | "provider-retry-current" + | "provider-success-current" + | "provider-timeout-current" + | "unknown-db-export" + | "unrelated-02-wcm" + | "unrelated-03-wsync" + | "unrelated-04-wsus" + | "wcm-failure-01-wcm" + | "wsus-failure-01-wcm" + | "wsus-failure-02-wsync" + | "wsus-failure-03-wsus" + | "z-site-status" + ) || SYNTHETIC_HIERARCHY_ARTIFACT_IDS.contains(&value); + } + opaque_sha256_handle(value, "cmtraceopen.artifact.sha256.v1:") +} + +fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> bool { + matches!( + value, + "server-sitecomp" + | "server-status" + | "server-mp-auth" + | "server-mp-policy" + | "server-mp-iis" + | "server-dp-distribution" + | "server-dp-serve" + | "server-hierarchy-control" + | "server-hierarchy-transfer" + | "server-sup-sync" + | "server-sup-wsus" + | "server-provider" + | "server-admin-service" + | "server-admin-service-iis" + | "unknown-db-supplement" + ) || (allow_unknown + && !synthetic_fixture + && opaque_sha256_handle(value, "cmtraceopen.source.sha256.v1:")) +} + +fn safe_source_kind(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> bool { + matches!( + value, + "ccmLog" | "iisW3c" | "structuredSupplement" | "profileDefined" | "unknown" + ) || (allow_unknown + && !synthetic_fixture + && opaque_sha256_handle(value, "cmtraceopen.source-kind.sha256.v1:")) +} + +fn safe_public_basename(value: &str, synthetic_fixture: bool) -> bool { + if synthetic_fixture { + value == "synthetic-db-export.txt" + } else { + opaque_sha256_handle(value, "cmtraceopen.basename.sha256.v1:") + } +} + +fn safe_lineage_id(value: &str, synthetic_fixture: bool) -> bool { + if synthetic_fixture { + return matches!( + value, + "dp-dist-lab" + | "dp-distribution-default" + | "absent-distmgr" + | "absent-provider" + | "distribution-failure" + | "healthy-distmgr" + | "healthy-pkgxfer" + | "healthy-provider" + | "incomplete-distmgr" + | "incomplete-pkgxfer" + | "incomplete-provider" + | "malformed-provider" + | "retry-distmgr" + | "retry-pkgxfer" + | "transfer-failure-distmgr" + | "transfer-failure-pkgxfer" + | "backlog-blocked-distmgr" + | "backlog-blocked-pkgxfer" + | "transfer-deferred-distmgr" + | "transfer-deferred-pkgxfer" + | "recovery-distmgr" + | "recovery-pkgxfer" + | "recovery-provider" + | "rotation-distmgr" + | "rotation-provider" + | "serve-distmgr" + | "serve-pkgxfer" + | "serve-provider" + | "serve-status" + | "validation-distmgr" + | "validation-pkgxfer" + | "validation-provider" + | "version-distmgr" + | "version-pkgxfer" + | "version-provider" + | "version-provider-dp02" + | "version-distmgr-dp02" + | "version-pkgxfer-dp02" + | "mp-iis-supplement" + | "mp-policy-a" + | "mp-policy-access" + | "mp-policy-configured" + | "mp-policy-lab" + | "mp-policy-multiline" + | "mp-policy-root-a" + | "mp-policy-root-b" + | "mp-policy-rotation" + | "site-status-z" + | "sitecomp-a" + | "sitecomp-lab" + | "sup-sync-cap" + | "sup-sync-lab" + | "sup-wsus-health" + | "provider-primary" + | "admin-service-primary" + | "admin-service-iis" + | "unknown-db-export" + ) || SYNTHETIC_HIERARCHY_LINEAGES.contains(&value); + } + opaque_sha256_handle(value, "cmtraceopen.lineage.sha256.v1:") +} + +fn safe_path_fingerprint(value: &str, synthetic_fixture: bool) -> bool { + if synthetic_fixture { + return matches!( + value, + "synthetic:path:a-mp" + | "synthetic:path:a-site" + | "synthetic:absent-distmgr" + | "synthetic:absent-provider" + | "synthetic:distribution-failure" + | "synthetic:healthy-distmgr" + | "synthetic:healthy-pkgxfer" + | "synthetic:healthy-provider" + | "synthetic:incomplete-distmgr" + | "synthetic:incomplete-pkgxfer" + | "synthetic:incomplete-provider" + | "synthetic:malformed-provider" + | "synthetic:retry-distmgr" + | "synthetic:retry-pkgxfer" + | "synthetic:transfer-failure-distmgr" + | "synthetic:transfer-failure-pkgxfer" + | "synthetic:backlog-blocked-distmgr" + | "synthetic:backlog-blocked-pkgxfer" + | "synthetic:transfer-deferred-distmgr" + | "synthetic:transfer-deferred-pkgxfer" + | "synthetic:recovery-distmgr" + | "synthetic:recovery-pkgxfer" + | "synthetic:recovery-provider" + | "synthetic:rotation-current" + | "synthetic:rotation-distmgr" + | "synthetic:rotation-lo" + | "synthetic:rotation-malformed" + | "synthetic:serve-distmgr" + | "synthetic:serve-pkgxfer" + | "synthetic:serve-provider" + | "synthetic:serve-status" + | "synthetic:validation-distmgr" + | "synthetic:validation-pkgxfer" + | "synthetic:validation-provider" + | "synthetic:version-distmgr" + | "synthetic:version-pkgxfer" + | "synthetic:version-provider" + | "synthetic:version-provider-dp02" + | "synthetic:version-distmgr-dp02" + | "synthetic:version-pkgxfer-dp02" + | "synthetic:path:dp-default" + | "synthetic:path:iis-not-requested" + | "synthetic:path:mp-configured-a" + | "synthetic:path:mp-default" + | "synthetic:path:mp-root-a" + | "synthetic:path:mp-root-b" + | "synthetic:path:site-default" + | "synthetic:path:site-dp-control" + | "synthetic:path:site-sup-control" + | "synthetic:path:sup-wsus-health" + | "synthetic:path:provider-primary" + | "synthetic:path:admin-service-primary" + | "synthetic:path:admin-service-iis" + | "synthetic:path:unsupported-db" + | "synthetic:path:z-site" + ) || SYNTHETIC_HIERARCHY_PATH_FINGERPRINTS.contains(&value); + } + opaque_sha256_handle(value, "cmtraceopen.path.sha256.v1:") +} + +fn safe_original_path_marker(value: &str, synthetic_fixture: bool) -> bool { + if synthetic_fixture { + return value.starts_with("REDACTED_") + && value.len() <= 96 + && value + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'); + } + value == "REDACTED" || opaque_sha256_handle(value, "cmtraceopen.original-path.sha256.v1:") +} + +fn normalize_opaque_extensions( + extensions: &BTreeMap, + scope_error: SccmServerIntakeError, +) -> Result, SccmServerIntakeError> { + let normalized = extensions + .iter() + .map(|(name, value)| { + let value = value.as_str().ok_or_else(|| scope_error.clone())?; + SccmServerOpaqueExtension::try_new(name.clone(), value.to_owned()) + .map_err(|_| scope_error.clone()) + }) + .collect::, _>>()?; + validate_opaque_extension_collection(&normalized).map_err(|_| scope_error)?; + Ok(normalized) +} + +fn validate_opaque_extension_collection( + extensions: &[SccmServerOpaqueExtension], +) -> Result<(usize, usize), &'static str> { + if extensions.len() > MAX_SCCM_SERVER_OPAQUE_EXTENSIONS { + return Err("opaque extension scope exceeds its count bound"); + } + let mut bytes = 0usize; + let mut previous_name: Option<&str> = None; + for extension in extensions { + extension + .validate() + .map_err(|_| "opaque extension record is invalid")?; + if previous_name.is_some_and(|previous| previous >= extension.name()) { + return Err("opaque extensions are duplicated or not canonically sorted"); + } + previous_name = Some(extension.name()); + bytes = bytes + .checked_add(extension.name().len()) + .and_then(|bytes| bytes.checked_add(extension.value().len())) + .ok_or("opaque extension byte count overflowed")?; + } + if bytes > MAX_SCCM_SERVER_OPAQUE_EXTENSION_BYTES_PER_SCOPE { + return Err("opaque extension scope exceeds its byte bound"); + } + Ok((extensions.len(), bytes)) +} + +fn serialize_opaque_extensions( + extensions: &[SccmServerOpaqueExtension], + serializer: S, +) -> Result +where + S: Serializer, +{ + validate_opaque_extension_collection(extensions).map_err(S::Error::custom)?; + extensions.serialize(serializer) +} + +fn validate_assessment_extensions( + assessment: &SccmServerIntakeAssessment, +) -> Result<(), &'static str> { + let mut total_count = 0usize; + let mut total_bytes = 0usize; + let mut add_scope = |extensions: &[SccmServerOpaqueExtension]| { + let (count, bytes) = validate_opaque_extension_collection(extensions)?; + total_count = total_count + .checked_add(count) + .ok_or("opaque extension aggregate count overflowed")?; + total_bytes = total_bytes + .checked_add(bytes) + .ok_or("opaque extension aggregate bytes overflowed")?; + if total_count > MAX_SCCM_SERVER_TOTAL_OPAQUE_EXTENSIONS + || total_bytes > MAX_SCCM_SERVER_TOTAL_OPAQUE_EXTENSION_BYTES + { + return Err("opaque extension aggregate exceeds its bound"); + } + Ok(()) + }; + + add_scope(&assessment.extensions)?; + add_scope(&assessment.privacy_extensions)?; + add_scope(&assessment.topology.extensions)?; + for artifact in &assessment.artifacts { + add_scope(&artifact.extensions)?; + add_scope(&artifact.workflow_subject_extensions)?; + add_scope(&artifact.configured_path_provenance_extensions)?; + add_scope(&artifact.rotation_extensions)?; + add_scope(&artifact.collection_limit_extensions)?; + } + Ok(()) +} + +fn safe_opaque_extension_name(value: &str) -> bool { + value + .strip_prefix("x-cmtraceopen-opaque-v1-") + .is_some_and(|token| { + (1..=64).contains(&token.len()) + && token.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && !token.ends_with('-') + && token + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) +} + +fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &str) -> bool { + let Some(value) = value else { + return true; + }; + if synthetic_fixture { + return match domain { + "host" => matches!( + value, + "synthetic:host:mp-01" + | "synthetic:host:site-01" + | "synthetic:host:site-02" + | "synthetic:host:site-03" + | "synthetic:host:wsus-01" + | "synthetic:host:provider-01" + | "synthetic:host:provider-02" + | "synthetic:host:admin-service-01" + ), + "subject" => { + matches!( + value, + "synthetic:subject:dp-01" + | "synthetic:subject:dp-02" + | "synthetic:subject:sup-01" + | "safe:sup:lab-sup-01" + | "synthetic:subject:provider-01" + | "synthetic:subject:admin-service-01" + ) + } + _ => false, + }; + } + opaque_sha256_handle(value, &format!("cmtraceopen.{domain}.sha256.v1:")) +} + +fn opaque_sha256_digest<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + value.strip_prefix(prefix).filter(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +fn opaque_sha256_handle(value: &str, prefix: &str) -> bool { + opaque_sha256_digest(value, prefix).is_some() +} + +fn is_declared_server_role(role: &SccmRole) -> bool { + matches!( + role, + SccmRole::SiteServer + | SccmRole::ManagementPoint + | SccmRole::DistributionPoint + | SccmRole::SoftwareUpdatePoint + | SccmRole::WsUs + | SccmRole::Provider + | SccmRole::AdminService + ) +} + +fn is_opaque_future_role(role: &SccmRole) -> bool { + matches!(role, SccmRole::Unknown(value) + if opaque_sha256_handle(value, "cmtraceopen.role.sha256.v1:")) +} + +fn role_sort_key(role: &SccmRole) -> &str { + match role { + SccmRole::Client => "client", + SccmRole::SiteServer => "siteServer", + SccmRole::ManagementPoint => "managementPoint", + SccmRole::DistributionPoint => "distributionPoint", + SccmRole::SoftwareUpdatePoint => "softwareUpdatePoint", + SccmRole::WsUs => "wsUs", + SccmRole::Provider => "provider", + SccmRole::AdminService => "adminService", + SccmRole::Unknown(value) => value, + } +} + +fn coverage_sort_key(state: &SccmCoverageState) -> &'static str { + match state { + SccmCoverageState::Captured => "captured", + SccmCoverageState::Absent => "absent", + SccmCoverageState::AccessDenied => "accessDenied", + SccmCoverageState::Capped => "capped", + SccmCoverageState::Skipped => "skipped", + SccmCoverageState::Unsupported => "unsupported", + SccmCoverageState::ParseFailed => "parseFailed", + } +} + +fn rotation_sort_key(rotation: Option<&SccmRotation>) -> String { + match rotation { + Some(SccmRotation::Timestamped(value)) => format!("0-timestamped-{value}"), + Some(SccmRotation::Numbered(value)) => { + format!("1-numbered-{:010}", u32::MAX - value) + } + Some(SccmRotation::LoUnderscore) => "2-lo-underscore".to_owned(), + Some(SccmRotation::Current) => "3-current".to_owned(), + Some(SccmRotation::Unknown(_)) => "4-unknown".to_owned(), + None => "5-not-applicable".to_owned(), + } +} + +#[derive(Debug)] +enum PreservedJsonValue { + Unsigned(u64), + String(String), + Array(Vec), + Object(Vec<(String, Self)>), + Other, +} + +struct PreservedJsonValueVisitor; + +impl<'de> Visitor<'de> for PreservedJsonValueVisitor { + type Value = PreservedJsonValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("any JSON value while preserving duplicate object keys") + } + + fn visit_bool(self, _value: bool) -> Result { + Ok(PreservedJsonValue::Other) + } + + fn visit_i64(self, _value: i64) -> Result { + Ok(PreservedJsonValue::Other) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(PreservedJsonValue::Unsigned(value)) + } + + fn visit_f64(self, _value: f64) -> Result { + Ok(PreservedJsonValue::Other) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(PreservedJsonValue::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(PreservedJsonValue::String(value)) + } + + fn visit_none(self) -> Result { + Ok(PreservedJsonValue::Other) + } + + fn visit_unit(self) -> Result { + Ok(PreservedJsonValue::Other) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(Self) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element()? { + values.push(value); + } + Ok(PreservedJsonValue::Array(values)) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut fields = Vec::new(); + while let Some(name) = map.next_key()? { + fields.push((name, map.next_value()?)); + } + Ok(PreservedJsonValue::Object(fields)) + } +} + +impl<'de> Deserialize<'de> for PreservedJsonValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(PreservedJsonValueVisitor) + } +} + +#[derive(Default)] +struct OpaqueExtensionTotals { + count: usize, + bytes: usize, +} + +impl OpaqueExtensionTotals { + fn add(&mut self, name: &str, value: &str) -> Result<(), SccmServerIntakeError> { + self.count = self + .count + .checked_add(1) + .ok_or(SccmServerIntakeError::ManifestLimitExceeded)?; + self.bytes = self + .bytes + .checked_add(name.len()) + .and_then(|bytes| bytes.checked_add(value.len())) + .ok_or(SccmServerIntakeError::ManifestLimitExceeded)?; + if self.count > MAX_SCCM_SERVER_TOTAL_OPAQUE_EXTENSIONS + || self.bytes > MAX_SCCM_SERVER_TOTAL_OPAQUE_EXTENSION_BYTES + { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + Ok(()) + } +} + +fn preflight_server_manifest_extensions(manifest_json: &str) -> Result<(), SccmServerIntakeError> { + let document: PreservedJsonValue = serde_json::from_str(manifest_json) + .map_err(|_| SccmServerIntakeError::MalformedManifest)?; + let PreservedJsonValue::Object(manifest) = &document else { + return Err(SccmServerIntakeError::MalformedManifest); + }; + validate_unique_object_fields(manifest, SccmServerIntakeError::MalformedManifest)?; + if !matches!( + preserved_field(manifest, "sccmManifestVersion"), + Some(PreservedJsonValue::Unsigned(1)) + ) { + return Ok(()); + } + let mut totals = OpaqueExtensionTotals::default(); + validate_preserved_extensions( + manifest, + RawServerManifest::KNOWN_FIELDS, + SccmServerIntakeError::MalformedManifest, + &mut totals, + )?; + + if let Some(PreservedJsonValue::Object(privacy)) = preserved_field(manifest, "privacy") { + validate_unique_object_fields(privacy, SccmServerIntakeError::MalformedManifest)?; + validate_preserved_extensions( + privacy, + RawServerPrivacy::KNOWN_FIELDS, + SccmServerIntakeError::MalformedManifest, + &mut totals, + )?; + } + if let Some(PreservedJsonValue::Object(topology)) = preserved_field(manifest, "topology") { + validate_unique_object_fields(topology, SccmServerIntakeError::InvalidTopology)?; + validate_preserved_extensions( + topology, + RawServerTopology::KNOWN_FIELDS, + SccmServerIntakeError::InvalidTopology, + &mut totals, + )?; + } + if let Some(PreservedJsonValue::Array(artifacts)) = preserved_field(manifest, "artifacts") { + if artifacts.len() > MAX_SCCM_SERVER_MANIFEST_ARTIFACTS { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + for artifact in artifacts { + let PreservedJsonValue::Object(artifact) = artifact else { + continue; + }; + validate_unique_object_fields(artifact, SccmServerIntakeError::InvalidArtifact)?; + validate_preserved_extensions( + artifact, + RawServerArtifact::KNOWN_FIELDS, + SccmServerIntakeError::InvalidArtifact, + &mut totals, + )?; + for (field, known) in [ + ("workflowSubject", RawWorkflowSubject::KNOWN_FIELDS), + ( + "configuredPathProvenance", + RawConfiguredPathProvenance::KNOWN_FIELDS, + ), + ("rotation", RawServerRotation::KNOWN_FIELDS), + ("collectionLimit", RawCollectionLimit::KNOWN_FIELDS), + ] { + if let Some(PreservedJsonValue::Object(nested)) = preserved_field(artifact, field) { + validate_unique_object_fields(nested, SccmServerIntakeError::InvalidArtifact)?; + validate_preserved_extensions( + nested, + known, + SccmServerIntakeError::InvalidArtifact, + &mut totals, + )?; + } + } + } + } + Ok(()) +} + +fn validate_unique_object_fields( + object: &[(String, PreservedJsonValue)], + scope_error: SccmServerIntakeError, +) -> Result<(), SccmServerIntakeError> { + let mut seen = BTreeSet::new(); + if object.iter().any(|(name, _)| !seen.insert(name.as_str())) { + return Err(scope_error); + } + Ok(()) +} + +fn preserved_field<'a>( + object: &'a [(String, PreservedJsonValue)], + name: &str, +) -> Option<&'a PreservedJsonValue> { + object + .iter() + .find_map(|(field, value)| (field == name).then_some(value)) +} + +fn validate_preserved_extensions( + object: &[(String, PreservedJsonValue)], + known_fields: &[&str], + scope_error: SccmServerIntakeError, + totals: &mut OpaqueExtensionTotals, +) -> Result<(), SccmServerIntakeError> { + let mut seen = BTreeSet::new(); + let mut count = 0usize; + let mut bytes = 0usize; + for (name, value) in object { + if known_fields.contains(&name.as_str()) { + continue; + } + let PreservedJsonValue::String(value) = value else { + return Err(scope_error); + }; + if !seen.insert(name.as_str()) + || !safe_opaque_extension_name(name) + || !opaque_sha256_handle(value, "cmtraceopen.extension.sha256.v1:") + { + return Err(scope_error); + } + count += 1; + bytes += name.len() + value.len(); + if count > MAX_SCCM_SERVER_OPAQUE_EXTENSIONS + || bytes > MAX_SCCM_SERVER_OPAQUE_EXTENSION_BYTES_PER_SCOPE + { + return Err(scope_error); + } + totals.add(name, value)?; + } + Ok(()) +} + +macro_rules! define_raw_server_wire { + ( + struct $name:ident { + $( + $(#[$field_meta:meta])* + $wire_name:literal => $field_name:ident: $field_type:ty + ),* $(,)? + } + ) => { + #[derive(Debug, Deserialize)] + struct $name { + $( + $(#[$field_meta])* + #[serde(rename = $wire_name)] + $field_name: $field_type, + )* + #[serde(flatten)] + extensions: BTreeMap, + } + + impl $name { + const KNOWN_FIELDS: &'static [&'static str] = &[$($wire_name),*]; + } + }; +} + +define_raw_server_wire! { + struct RawServerManifest { + "sccmManifestVersion" => sccm_manifest_version: u32, + #[serde(default)] + "syntheticFixture" => synthetic_fixture: bool, + "proposalOnly" => proposal_only: Option, + "privacy" => privacy: Option, + "bundleRole" => bundle_role: String, + "topology" => topology: RawServerTopology, + "inputOrderIsDeliberatelyUnsorted" => input_order_is_deliberately_unsorted: Option, + "artifacts" => artifacts: Vec, + } +} + +define_raw_server_wire! { + struct RawServerPrivacy { + "synthetic" => synthetic: bool, + "rawPaths" => raw_paths: String, + } +} + +define_raw_server_wire! { + struct RawServerTopology { + "captureHost" => capture_host: String, + "siteCode" => site_code: String, + "rolesObserved" => roles_observed: Vec, + #[serde(default)] + "hierarchyLinks" => hierarchy_links: Vec, + } +} + +define_raw_server_wire! { + struct RawServerHierarchyLink { + "originSiteCode" => origin_site_code: String, + "targetSiteCode" => target_site_code: String, + "originHostHandle" => origin_host_handle: String, + "targetHostHandle" => target_host_handle: String, + } +} + +define_raw_server_wire! { + struct RawServerArtifact { + "artifactId" => artifact_id: String, + "producerRole" => producer_role: SccmRole, + "producerHostHandle" => producer_host_handle: Option, + "workflowSubject" => workflow_subject: Option, + "sourceId" => source_id: String, + "sourceKind" => source_kind: String, + "sourceVersion" => source_version: Option, + "originalPath" => original_path: String, + "originalBasename" => original_basename: String, + "configuredPathProvenance" => configured_path_provenance: RawConfiguredPathProvenance, + "defaultCandidateState" => default_candidate_state: Option, + "rotation" => rotation: RawServerRotation, + "captureState" => capture_state: SccmCoverageState, + "collectionDetail" => collection_detail: Option, + "skipReason" => skip_reason: Option, + "unsupportedReason" => unsupported_reason: Option, + "encoding" => encoding: Option, + "collectionLimit" => collection_limit: Option, + "truncated" => truncated: Option, + "fragmentComplete" => fragment_complete: Option, + "collectedUtc" => collected_utc: String, + "relativePath" => relative_path: Option, + "bytesCopied" => bytes_copied: u64, + } +} + +define_raw_server_wire! { + struct RawWorkflowSubject { + "role" => role: SccmRole, + "instanceHandle" => instance_handle: Option, + "basis" => basis: Option, + } +} + +define_raw_server_wire! { + struct RawConfiguredPathProvenance { + "state" => state: String, + "pathClass" => path_class: Option, + "pathFingerprint" => path_fingerprint: String, + } +} + +define_raw_server_wire! { + struct RawServerRotation { + "kind" => kind: String, + "value" => value: Option, + "lineageId" => lineage_id: String, + } +} + +define_raw_server_wire! { + struct RawCollectionLimit { + "byteLimit" => byte_limit: u64, + "fileLimit" => file_limit: Option, + "limitApplied" => limit_applied: bool, + } +} + +#[cfg(test)] +mod artifact_family_integrity_key_tests { + use super::*; + + #[test] + fn artifact_family_integrity_keys_match_the_frozen_serialized_mapping() { + let cases = [ + (SccmArtifactFamily::ClientSetup, "clientSetup"), + (SccmArtifactFamily::ClientHealth, "clientHealth"), + (SccmArtifactFamily::ClientIdentity, "clientIdentity"), + (SccmArtifactFamily::ClientLocation, "clientLocation"), + (SccmArtifactFamily::ClientPolicy, "clientPolicy"), + (SccmArtifactFamily::ClientContent, "clientContent"), + (SccmArtifactFamily::ClientApplication, "clientApplication"), + (SccmArtifactFamily::ClientUpdates, "clientUpdates"), + (SccmArtifactFamily::ClientTaskSequence, "clientTaskSequence"), + (SccmArtifactFamily::SiteComponent, "siteComponent"), + (SccmArtifactFamily::SiteStatus, "siteStatus"), + (SccmArtifactFamily::ManagementPoint, "managementPoint"), + (SccmArtifactFamily::DistributionPoint, "distributionPoint"), + ( + SccmArtifactFamily::SoftwareUpdatePoint, + "softwareUpdatePoint", + ), + (SccmArtifactFamily::Hierarchy, "hierarchy"), + (SccmArtifactFamily::Provider, "provider"), + (SccmArtifactFamily::AdminService, "adminService"), + ( + SccmArtifactFamily::Unknown("opaqueFamily".to_owned()), + "opaqueFamily", + ), + ]; + + for (family, expected) in cases { + assert_eq!(artifact_family_integrity_key(&family), expected); + assert_eq!( + serde_json::to_value(family).expect("family must serialize"), + Value::String(expected.to_owned()) + ); + } + } +} + +#[cfg(test)] +mod opaque_extension_boundary_tests { + use super::*; + + const EXPECTED_MAX_TOTAL_OPAQUE_EXTENSIONS: usize = 1_024; + + fn valid_extension(name: &str, ordinal: usize) -> SccmServerOpaqueExtension { + SccmServerOpaqueExtension { + schema_version: 1, + name: name.to_owned(), + value: format!("cmtraceopen.extension.sha256.v1:{ordinal:064x}"), + } + } + + #[test] + fn opaque_extension_validation_distinguishes_unsupported_schema_version() { + let extension = SccmServerOpaqueExtension { + schema_version: 2, + name: "x-cmtraceopen-opaque-v1-safe".to_owned(), + value: "cmtraceopen.extension.sha256.v1:0000000000000000000000000000000000000000000000000000000000000001" + .to_owned(), + }; + + assert_eq!( + extension.validate(), + Err(SccmServerOpaqueExtensionError::UnsupportedSchemaVersion { schema_version: 2 }) + ); + } + + #[test] + fn raw_manifest_known_fields_match_the_generated_wire_contracts() { + assert_eq!( + RawServerManifest::KNOWN_FIELDS, + [ + "sccmManifestVersion", + "syntheticFixture", + "proposalOnly", + "privacy", + "bundleRole", + "topology", + "inputOrderIsDeliberatelyUnsorted", + "artifacts", + ] + ); + assert_eq!(RawServerPrivacy::KNOWN_FIELDS, ["synthetic", "rawPaths"]); + assert_eq!( + RawServerTopology::KNOWN_FIELDS, + ["captureHost", "siteCode", "rolesObserved", "hierarchyLinks"] + ); + assert_eq!( + RawServerHierarchyLink::KNOWN_FIELDS, + [ + "originSiteCode", + "targetSiteCode", + "originHostHandle", + "targetHostHandle" + ] + ); + assert_eq!( + RawWorkflowSubject::KNOWN_FIELDS, + ["role", "instanceHandle", "basis"] + ); + assert_eq!( + RawConfiguredPathProvenance::KNOWN_FIELDS, + ["state", "pathClass", "pathFingerprint"] + ); + assert_eq!( + RawServerRotation::KNOWN_FIELDS, + ["kind", "value", "lineageId"] + ); + assert_eq!( + RawCollectionLimit::KNOWN_FIELDS, + ["byteLimit", "fileLimit", "limitApplied"] + ); + assert_eq!( + RawServerArtifact::KNOWN_FIELDS, + [ + "artifactId", + "producerRole", + "producerHostHandle", + "workflowSubject", + "sourceId", + "sourceKind", + "sourceVersion", + "originalPath", + "originalBasename", + "configuredPathProvenance", + "defaultCandidateState", + "rotation", + "captureState", + "collectionDetail", + "skipReason", + "unsupportedReason", + "encoding", + "collectionLimit", + "truncated", + "fragmentComplete", + "collectedUtc", + "relativePath", + "bytesCopied", + ] + ); + } + + fn empty_assessment() -> SccmServerIntakeAssessment { + assess_server_intake( + r#"{ + "sccmManifestVersion": 1, + "bundleRole": "server", + "topology": { + "captureHost": "cmtraceopen.host.sha256.v1:0000000000000000000000000000000000000000000000000000000000000001", + "siteCode": "cmtraceopen.site.sha256.v1:0000000000000000000000000000000000000000000000000000000000000001", + "rolesObserved": ["managementPoint"] + }, + "artifacts": [] + }"#, + &[], + ) + .expect("minimal production assessment is valid") + } + + #[test] + fn opaque_extension_serialize_rejects_unsafe_internal_mutation() { + let extension = SccmServerOpaqueExtension { + schema_version: 1, + name: "real-user-extension".to_owned(), + value: "Real User ".repeat(512), + }; + + assert!( + serde_json::to_string(&extension).is_err(), + "unsafe identity-bearing extensions cannot cross public serialization" + ); + } + + #[test] + fn opaque_extension_public_construction_and_serde_are_validated() { + assert_eq!( + SccmServerOpaqueExtension::try_new( + "real-user-extension", + "cmtraceopen.extension.sha256.v1:0000000000000000000000000000000000000000000000000000000000000001", + ), + Err(SccmServerOpaqueExtensionError::InvalidName) + ); + let extension = SccmServerOpaqueExtension::try_new( + "x-cmtraceopen-opaque-v1-safe", + "cmtraceopen.extension.sha256.v1:0000000000000000000000000000000000000000000000000000000000000001", + ) + .expect("safe extension construction succeeds"); + let public = serde_json::to_value(&extension).expect("safe extension serializes"); + let round_trip = serde_json::from_value::(public) + .expect("safe extension deserializes"); + assert_eq!(round_trip, extension); + + for invalid in [ + serde_json::json!({ + "schemaVersion": 2, + "name": "x-cmtraceopen-opaque-v1-safe", + "value": "cmtraceopen.extension.sha256.v1:0000000000000000000000000000000000000000000000000000000000000001", + }), + serde_json::json!({ + "schemaVersion": 1, + "name": "real-user-extension", + "value": "cmtraceopen.extension.sha256.v1:0000000000000000000000000000000000000000000000000000000000000001", + }), + serde_json::json!({ + "schemaVersion": 1, + "name": "x-cmtraceopen-opaque-v1-safe", + "value": "Real User ", + }), + serde_json::json!({ + "schemaVersion": 1, + "name": "x-cmtraceopen-opaque-v1-safe", + "value": "cmtraceopen.extension.sha256.v1:0000000000000000000000000000000000000000000000000000000000000001", + "extra": "cmtraceopen.extension.sha256.v1:0000000000000000000000000000000000000000000000000000000000000002", + }), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } + } + + #[test] + fn assessment_serialize_rejects_duplicate_or_unsorted_extensions() { + let mut duplicate = empty_assessment(); + duplicate.extensions = vec![ + valid_extension("x-cmtraceopen-opaque-v1-same", 1), + valid_extension("x-cmtraceopen-opaque-v1-same", 2), + ]; + assert!(serde_json::to_string(&duplicate).is_err()); + + let mut unsorted = empty_assessment(); + unsorted.extensions = vec![ + valid_extension("x-cmtraceopen-opaque-v1-zulu", 3), + valid_extension("x-cmtraceopen-opaque-v1-alpha", 4), + ]; + assert!(serde_json::to_string(&unsorted).is_err()); + } + + #[test] + fn assessment_serialize_bounds_per_scope_and_aggregate_extensions() { + assert_eq!( + EXPECTED_MAX_TOTAL_OPAQUE_EXTENSIONS, MAX_SCCM_SERVER_TOTAL_OPAQUE_EXTENSIONS, + "the pinned aggregate extension bound must track the production contract" + ); + let mut per_scope = empty_assessment(); + per_scope.extensions = (0..=MAX_SCCM_SERVER_OPAQUE_EXTENSIONS) + .map(|ordinal| { + valid_extension( + &format!("x-cmtraceopen-opaque-v1-item-{ordinal:02}"), + ordinal, + ) + }) + .collect(); + assert!(serde_json::to_string(&per_scope).is_err()); + + let mut aggregate = empty_assessment(); + let mut artifact = SccmServerArtifactAssessment { + artifact_id: "unused".to_owned(), + producer_role: SccmRole::ManagementPoint, + producer_host_handle: None, + workflow_subject_role: None, + workflow_subject_handle: None, + source_id: "unused".to_owned(), + source_kind: "unused".to_owned(), + family: SccmArtifactFamily::Unknown("unused".to_owned()), + original_basename: None, + rotation: None, + rotation_lineage_handle: "unused".to_owned(), + state: SccmCoverageState::Unsupported, + configured_path_state: SccmServerConfiguredPathState::Supplied, + configured_path_class: None, + path_fingerprint: "unused".to_owned(), + source_version: None, + profile_eligible: false, + collected_at_utc: "2026-07-30T00:00:00Z".to_owned(), + relative_path: None, + bytes_copied: 0, + content_sha256: None, + truncated: None, + fragment_complete: None, + collection_limit: None, + capture_provenance: None, + parser_eligible: false, + extensions: (0..MAX_SCCM_SERVER_OPAQUE_EXTENSIONS) + .map(|ordinal| { + valid_extension( + &format!("x-cmtraceopen-opaque-v1-item-{ordinal:02}"), + ordinal, + ) + }) + .collect(), + workflow_subject_extensions: Vec::new(), + configured_path_provenance_extensions: Vec::new(), + rotation_extensions: Vec::new(), + collection_limit_extensions: Vec::new(), + }; + for ordinal in 0..=EXPECTED_MAX_TOTAL_OPAQUE_EXTENSIONS / MAX_SCCM_SERVER_OPAQUE_EXTENSIONS + { + artifact.artifact_id = format!("unused-{ordinal}"); + aggregate.artifacts.push(artifact.clone()); + } + assert!(serde_json::to_string(&aggregate).is_err()); + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs new file mode 100644 index 000000000..51367bd66 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -0,0 +1,1989 @@ +//! Server-local Management Point analysis for issue #328. +//! +//! The only selected extraction profile in this slice is +//! `mp-server-5.00.test-v1` for the synthetic `5.00.TEST` corpus. A +//! transaction requires an exact request ID, optional exact policy ID, safe +//! client handle, canonical site code, compatible Management Point handle, +//! source ownership, complete physical provenance, and usable ordering +//! provenance. `counterpart_ready_facts` are the contractual #333 handoff; +//! this module never performs client/server correlation or infers a client +//! cause. + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use thiserror::Error; + +use crate::models::log_entry::Severity; +use crate::sccm::findings::evidence_references_overlap; +use crate::sccm::{ + classify_artifact_name, SccmArtifact, SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, + SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFinding, SccmFindingBuilder, + SccmFindingClass, SccmFindingCoverageGap, SccmKeyConfidence, SccmPhase, SccmRole, + SccmTerminalEvidence, SccmTimestamp, +}; + +use super::intake::{SccmServerArtifactAssessment, SccmServerIntakeAssessment}; + +pub const SCCM_MANAGEMENT_POINT_ANALYSIS_SCHEMA_VERSION: u32 = 1; +pub const SCCM_MANAGEMENT_POINT_TEST_PROFILE_ID: &str = "mp-server-5.00.test-v1"; + +const MP_TEST_VERSION: &str = "5.00.TEST"; +const MP_AUTH_GROUP: &str = "server-mp-auth"; +const MP_POLICY_GROUP: &str = "server-mp-policy"; +const MP_IIS_GROUP: &str = "server-mp-iis"; + +/// Canonical server intake rejected a source or topology before it reached +/// Management Point reduction. Callers must retain the intake assessment and +/// its coverage output rather than attempting a role diagnosis from a partial +/// substitute bundle. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum SccmManagementPointIntakeError { + #[error("canonical server intake has no compatible Management Point topology")] + TopologyMismatch, + #[error("canonical Management Point source has an incompatible producer role: {artifact_id}")] + RoleMismatch { artifact_id: String }, + #[error( + "canonical Management Point source has an unsupported extraction profile: {artifact_id}" + )] + ProfileMismatch { artifact_id: String }, + #[error("canonical server intake has an incompatible Management Point source: {artifact_id}")] + SourceMismatch { artifact_id: String }, + #[error("canonical Management Point source is incomplete or non-captured: {artifact_id}")] + IncompleteSource { artifact_id: String }, +} + +const STATE_CHAIN: [SccmManagementPointPhase; 6] = [ + SccmManagementPointPhase::ReceiveRequest, + SccmManagementPointPhase::Authenticate, + SccmManagementPointPhase::RegisterOrIdentify, + SccmManagementPointPhase::ResolveLocationOrPolicy, + SccmManagementPointPhase::Respond, + SccmManagementPointPhase::RecordOutcome, +]; + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmManagementPointTopology { + pub site_code: String, + pub management_point_host_handle: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmManagementPointSource { + pub artifact: SccmArtifact, + pub source_group: String, + pub producer: String, + pub fragment_complete: Option, + pub physical_line_end: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmManagementPointBundle { + pub topology: SccmManagementPointTopology, + pub sources: Vec, + pub evidence: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmServerWorkflow { + ManagementPoint, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmManagementPointPhase { + ReceiveRequest, + Authenticate, + RegisterOrIdentify, + ResolveLocationOrPolicy, + Respond, + RecordOutcome, +} + +impl SccmManagementPointPhase { + fn serialized_name(self) -> &'static str { + match self { + Self::ReceiveRequest => "receiveRequest", + Self::Authenticate => "authenticate", + Self::RegisterOrIdentify => "registerOrIdentify", + Self::ResolveLocationOrPolicy => "resolveLocationOrPolicy", + Self::Respond => "respond", + Self::RecordOutcome => "recordOutcome", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmManagementPointState { + Succeeded, + Failed, + Deferred, + Incomplete, + Contradictory, + Observed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmManagementPointClassification { + Success, + ConfirmedFailure, + BlockedOrDeferred, + InsufficientEvidence, + ContradictoryEvidence, + LowConfidenceSymptom, + IncompatibleKey, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmManagementPointConfidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmManagementPointKey { + pub request_id: String, + pub policy_id: Option, + pub client_handle: String, + pub site_code: String, + pub management_point_host_handle: String, + pub confidence: SccmKeyConfidence, + pub extraction_profile_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmManagementPointArtifactRequest { + pub logical_artifact_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmManagementPointObservation { + pub observation_id: String, + pub phase: SccmManagementPointPhase, + pub state: SccmManagementPointState, + pub classification: SccmManagementPointClassification, + pub timestamp: SccmTimestamp, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmManagementPointTransaction { + pub transaction_id: String, + pub key: SccmManagementPointKey, + pub phase: SccmManagementPointPhase, + pub state: SccmManagementPointState, + pub last_successful_phase: Option, + pub classification: SccmManagementPointClassification, + pub confidence: SccmManagementPointConfidence, + pub evidence: Vec, + pub observations: Vec, + pub coverage_gap_artifact_ids: Vec, + pub next_artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmManagementPointSourceLocalObservation { + pub observation_id: String, + pub phase: SccmManagementPointPhase, + pub state: SccmManagementPointState, + pub classification: SccmManagementPointClassification, + pub confidence: SccmManagementPointConfidence, + pub correlation_eligible: bool, + pub evidence: Vec, + pub next_artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmManagementPointCoverageGap { + pub logical_artifact_id: String, + pub role: SccmRole, + pub state: SccmCoverageState, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmManagementPointCounterpartReadyFact { + pub transaction_id: String, + pub key: SccmManagementPointKey, + pub phase: SccmManagementPointPhase, + pub state: SccmManagementPointState, + pub classification: SccmManagementPointClassification, + pub confidence: SccmManagementPointConfidence, + pub timestamp: SccmTimestamp, + pub evidence: SccmEvidenceRef, + pub terminal_evidence: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmManagementPointFinding { + #[serde(flatten)] + pub finding: SccmFinding, + pub subject_id: String, + pub last_successful_phase: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmManagementPointAnalysis { + pub schema_version: u32, + pub workflow: SccmServerWorkflow, + pub state_chain: Vec, + pub transactions: Vec, + pub source_local_observations: Vec, + pub findings: Vec, + pub coverage_gaps: Vec, + pub artifact_requests: Vec, + pub counterpart_ready_facts: Vec, + pub cross_side_correlation_performed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FactOutcome { + Succeeded, + Failed, + Deferred, +} + +#[derive(Debug, Clone)] +struct ManagementPointFact { + request_id: String, + policy_id: Option, + client_handle: String, + site_code: String, + management_point_host_handle: String, + phase: SccmManagementPointPhase, + outcome: FactOutcome, + terminal: bool, + reference: SccmEvidenceRef, + timestamp: SccmTimestamp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PhaseDecisionKind { + Succeeded, + Failed, + Deferred, + Contradictory, + UnusableTime, +} + +struct PhaseDecision<'a> { + kind: PhaseDecisionKind, + decisive: Vec<&'a ManagementPointFact>, + ordering_millis: Option, +} + +struct ReducedTransaction { + transaction: SccmManagementPointTransaction, + finding: Option, + counterpart_fact: Option, + coverage_gap: Option, +} + +/// Reduce Management Point evidence only after canonical server intake has +/// admitted its topology, artifact provenance, and CCM records for the +/// synthetic `mp-server-5.00.test-v1` profile. This adapter admits only the +/// literal `synthetic:site:lab` site handle and exact `5.00.TEST` source +/// version; canonical non-synthetic intake bundles intentionally return +/// [`SccmManagementPointIntakeError::TopologyMismatch`]. +/// +/// This is the server-intake entry point. It preserves the intake artifact IDs +/// and producer-host handles exactly; it never reconstructs role facts from a +/// caller-supplied path or raw log payload. +pub fn analyze_management_point_from_server_intake( + assessment: &SccmServerIntakeAssessment, +) -> Result { + if !assessment.adapter_authority_is_intake_bound() { + return Err(SccmManagementPointIntakeError::SourceMismatch { + artifact_id: "management-point-intake-projection".to_owned(), + }); + } + if !assessment + .topology + .roles_observed + .contains(&SccmRole::ManagementPoint) + { + return Err(SccmManagementPointIntakeError::TopologyMismatch); + } + + let site_code = canonical_intake_site_code(&assessment.topology.site_handle) + .ok_or(SccmManagementPointIntakeError::TopologyMismatch)?; + let mut sources = Vec::new(); + let mut host_handles = BTreeSet::new(); + + for artifact in assessment.artifacts.iter().filter(|artifact| { + matches!(artifact.source_id.as_str(), MP_AUTH_GROUP | MP_POLICY_GROUP) + && artifact.workflow_subject_role.is_none() + }) { + if artifact.producer_role != SccmRole::ManagementPoint { + return Err(SccmManagementPointIntakeError::RoleMismatch { + artifact_id: artifact.artifact_id.clone(), + }); + } + if artifact.state != SccmCoverageState::Captured + || artifact.fragment_complete == Some(false) + || artifact.truncated == Some(true) + || !artifact.parser_eligible + { + return Err(SccmManagementPointIntakeError::IncompleteSource { + artifact_id: artifact.artifact_id.clone(), + }); + } + if !assessment.coverage.iter().any(|coverage| { + coverage.producer_role == SccmRole::ManagementPoint + && coverage.workflow_subject_role.is_none() + && coverage.source_id == artifact.source_id + && coverage.state == artifact.state + && coverage.artifact_ids.contains(&artifact.artifact_id) + }) { + return Err(SccmManagementPointIntakeError::SourceMismatch { + artifact_id: artifact.artifact_id.clone(), + }); + } + if !artifact.profile_eligible || !management_point_profile_is_admitted(artifact) { + return Err(SccmManagementPointIntakeError::ProfileMismatch { + artifact_id: artifact.artifact_id.clone(), + }); + } + + let host_handle = artifact + .producer_host_handle + .as_deref() + .filter(|handle| valid_management_point_handle(handle)) + .ok_or(SccmManagementPointIntakeError::TopologyMismatch)?; + host_handles.insert(host_handle.to_owned()); + + let producer = canonical_intake_producer(artifact)?; + let rotation = artifact.rotation.clone().ok_or_else(|| { + SccmManagementPointIntakeError::SourceMismatch { + artifact_id: artifact.artifact_id.clone(), + } + })?; + let display_name = artifact.original_basename.clone().ok_or_else(|| { + SccmManagementPointIntakeError::SourceMismatch { + artifact_id: artifact.artifact_id.clone(), + } + })?; + // Intake seals every logical-record range before this adapter runs. + // The assessment has no independent physical line count, so this bound + // is derived from that sealed evidence; `evidence_reference_fits_source` + // cannot reject an otherwise-admitted range against its own maximum. + // Relaxing the seal would make this caller-controlled and requires + // re-review. + let physical_line_end = assessment + .evidence + .iter() + .filter(|evidence| evidence.reference.artifact_id == artifact.artifact_id) + .filter_map(|evidence| evidence.reference.line_end) + .max(); + + sources.push(SccmManagementPointSource { + artifact: SccmArtifact { + artifact_id: artifact.artifact_id.clone(), + display_name, + original_path: None, + host: Some(host_handle.to_owned()), + role: artifact.producer_role.clone(), + configmgr_version: artifact.source_version.clone(), + collected_at_utc: Some(artifact.collected_at_utc.clone()), + rotation, + coverage: artifact.state.clone(), + encoding: artifact + .capture_provenance + .as_ref() + .map(|provenance| provenance.encoding.clone()), + }, + source_group: artifact.source_id.clone(), + producer, + // The canonical intake contract expresses a complete capture with + // neither truncation nor fragment flags. The legacy fixture + // reducer uses an explicit complete bit, so translate only that + // canonical state at this adapter boundary. + fragment_complete: canonical_fragment_complete(artifact), + physical_line_end, + }); + } + + if sources.is_empty() { + return Err(SccmManagementPointIntakeError::SourceMismatch { + artifact_id: "management-point-source-set".to_owned(), + }); + } + let management_point_host_handle = if host_handles.len() == 1 { + host_handles.into_iter().next().expect("one host handle") + } else { + return Err(SccmManagementPointIntakeError::TopologyMismatch); + }; + + sources.sort_by(|left, right| left.artifact.artifact_id.cmp(&right.artifact.artifact_id)); + let admitted_artifact_ids = sources + .iter() + .map(|source| source.artifact.artifact_id.as_str()) + .collect::>(); + let mut evidence = assessment + .evidence + .iter() + .filter(|evidence| { + evidence.role == SccmRole::ManagementPoint + && admitted_artifact_ids.contains(evidence.reference.artifact_id.as_str()) + }) + .cloned() + .collect::>(); + evidence.sort_by(|left, right| left.evidence_id.cmp(&right.evidence_id)); + + Ok(analyze_management_point_fixture( + &SccmManagementPointBundle { + topology: SccmManagementPointTopology { + site_code, + management_point_host_handle, + }, + sources, + evidence, + }, + )) +} + +fn canonical_fragment_complete(artifact: &SccmServerArtifactAssessment) -> Option { + match ( + artifact.state.clone(), + artifact.truncated, + artifact.fragment_complete, + ) { + (SccmCoverageState::Captured, None, None) => Some(true), + _ => artifact.fragment_complete, + } +} + +/// Maps the sole synthetic site handle admitted by this adapter. Opaque +/// production handles intentionally do not select the test-only profile. +fn canonical_intake_site_code(site_handle: &str) -> Option { + (site_handle == "synthetic:site:lab").then(|| "LAB".to_owned()) +} + +fn management_point_profile_is_admitted(artifact: &SccmServerArtifactAssessment) -> bool { + artifact.source_version.as_deref() == Some("5.00.TEST") + && artifact.source_kind == "ccmLog" + && artifact.family == SccmArtifactFamily::ManagementPoint +} + +fn canonical_intake_producer( + artifact: &SccmServerArtifactAssessment, +) -> Result { + let basename = artifact.original_basename.as_deref(); + let producer = match (artifact.source_id.as_str(), basename) { + (MP_AUTH_GROUP, Some("MP_GetAuth.log")) => "MP_GetAuth", + (MP_AUTH_GROUP, Some("MP_CliReg.log")) => "MP_CliReg", + (MP_AUTH_GROUP, Some("MP_RegistrationManager.log")) => "MP_RegistrationManager", + (MP_POLICY_GROUP, Some("MP_GetPolicy.log")) => "MP_GetPolicy", + (MP_POLICY_GROUP, Some("MP_Location.log")) => "MP_Location", + _ => { + return Err(SccmManagementPointIntakeError::SourceMismatch { + artifact_id: artifact.artifact_id.clone(), + }); + } + }; + Ok(producer.to_owned()) +} + +/// Legacy synthetic-fixture hook. Production callers must enter through +/// [`analyze_management_point_from_server_intake`]. +pub(crate) fn analyze_management_point_fixture( + bundle: &SccmManagementPointBundle, +) -> SccmManagementPointAnalysis { + let source_by_artifact = bundle + .sources + .iter() + .filter(|source| { + safe_opaque_id(&source.artifact.artifact_id) + && artifact_id_is_unique(bundle, &source.artifact.artifact_id) + }) + .map(|source| (source.artifact.artifact_id.as_str(), source)) + .collect::>(); + + let topology_site_code = normalize_site_code(&bundle.topology.site_code); + let topology_is_valid = topology_site_code.is_some() + && valid_management_point_handle(&bundle.topology.management_point_host_handle); + let mut facts_by_request: BTreeMap> = BTreeMap::new(); + let mut rejected_references = Vec::new(); + + for evidence in &bundle.evidence { + let Some(source) = source_by_artifact.get(evidence.reference.artifact_id.as_str()) else { + continue; + }; + if is_supplemental_source(source) { + continue; + } + if source.artifact.coverage != SccmCoverageState::Captured + || source.fragment_complete != Some(true) + { + // Bytes retained beside a noncaptured manifest state are + // coverage-only. They cannot become malformed evidence or an + // outcome. + continue; + } + if !source_is_admitted(source) + || !evidence_reference_fits_source(evidence, source) + || !evidence_identity_is_unique(bundle, evidence) + || evidence.role != SccmRole::ManagementPoint + || !topology_is_valid + { + if evidence_reference_fits_source(evidence, source) { + rejected_references.push(evidence.reference.clone()); + } + continue; + } + + match parse_fact(evidence, source) { + Some(fact) + if topology_site_code.as_deref() == Some(fact.site_code.as_str()) + && fact.management_point_host_handle + == bundle.topology.management_point_host_handle => + { + facts_by_request + .entry(fact.request_id.clone()) + .or_default() + .push(fact); + } + _ => rejected_references.push(evidence.reference.clone()), + } + } + + let mut transactions = Vec::new(); + let mut findings = Vec::new(); + let mut counterpart_ready_facts = Vec::new(); + let mut coverage_gaps = Vec::new(); + let mut consumed_gap_groups = BTreeSet::new(); + + for facts in facts_by_request.values_mut() { + sort_facts(facts); + if let Some(reduced) = reduce_transaction(facts, bundle) { + if let Some(gap) = reduced.coverage_gap { + consumed_gap_groups.insert(gap.logical_artifact_id.clone()); + coverage_gaps.push(gap); + } + if let Some(finding) = reduced.finding { + findings.push(finding); + } + if let Some(fact) = reduced.counterpart_fact { + counterpart_ready_facts.push(fact); + } + transactions.push(reduced.transaction); + } else { + rejected_references.extend(facts.iter().map(|fact| fact.reference.clone())); + } + } + + let mut source_local_observations = Vec::new(); + append_rotation_fragment_observation( + bundle, + &mut source_local_observations, + &mut findings, + &mut coverage_gaps, + &mut consumed_gap_groups, + ); + append_rejected_observations( + bundle, + &mut rejected_references, + &mut source_local_observations, + &mut findings, + ); + append_unconsumed_explicit_coverage( + bundle, + &mut source_local_observations, + &mut findings, + &mut coverage_gaps, + &mut consumed_gap_groups, + ); + + normalize_analysis( + &mut transactions, + &mut source_local_observations, + &mut findings, + &mut coverage_gaps, + &mut counterpart_ready_facts, + ); + let artifact_requests = collect_artifact_requests(&transactions, &source_local_observations); + + SccmManagementPointAnalysis { + schema_version: SCCM_MANAGEMENT_POINT_ANALYSIS_SCHEMA_VERSION, + workflow: SccmServerWorkflow::ManagementPoint, + state_chain: STATE_CHAIN.to_vec(), + transactions, + source_local_observations, + findings, + coverage_gaps, + artifact_requests, + counterpart_ready_facts, + cross_side_correlation_performed: false, + } +} + +fn reduce_transaction( + facts: &[ManagementPointFact], + bundle: &SccmManagementPointBundle, +) -> Option { + let request_fact = facts.iter().find(|fact| { + fact.phase == SccmManagementPointPhase::ReceiveRequest + && fact.outcome == FactOutcome::Succeeded + })?; + let key = build_transaction_key(facts, request_fact)?; + + let mut observations = facts + .iter() + .enumerate() + .map(|(index, fact)| observation_for_fact(&key.request_id, index, fact)) + .collect::>(); + observations.sort_by(compare_observations); + + let mut last_successful_phase = None; + let mut previous_millis = None; + let mut phase = SccmManagementPointPhase::ReceiveRequest; + let mut state = SccmManagementPointState::Incomplete; + let mut classification = SccmManagementPointClassification::InsufficientEvidence; + let mut confidence = SccmManagementPointConfidence::Medium; + let mut decisive_facts = Vec::new(); + let mut gap = None; + let mut next_artifacts = Vec::new(); + + for current_phase in STATE_CHAIN { + phase = current_phase; + let phase_facts = facts + .iter() + .filter(|fact| fact.phase == current_phase) + .collect::>(); + if phase_facts.is_empty() { + phase = last_successful_phase.unwrap_or(current_phase); + state = SccmManagementPointState::Incomplete; + classification = SccmManagementPointClassification::InsufficientEvidence; + confidence = SccmManagementPointConfidence::Medium; + let missing_group = group_for_phase(current_phase); + let request = + workflow_request_for_group(missing_group, missing_phase_reason(current_phase)); + next_artifacts.push(request); + gap = Some(SccmManagementPointCoverageGap { + logical_artifact_id: missing_group.to_owned(), + role: SccmRole::ManagementPoint, + state: coverage_for_group(bundle, missing_group), + }); + break; + } + + let decision = resolve_phase(&phase_facts); + decisive_facts = decision.decisive.clone(); + let inverted = previous_millis + .zip(decision.ordering_millis) + .is_some_and(|(previous, current)| current <= previous); + if inverted { + state = SccmManagementPointState::Contradictory; + classification = SccmManagementPointClassification::ContradictoryEvidence; + confidence = SccmManagementPointConfidence::Low; + next_artifacts.push(workflow_request_for_group( + group_for_phase(current_phase), + chronology_reason(current_phase), + )); + break; + } + + match decision.kind { + PhaseDecisionKind::Succeeded => { + last_successful_phase = Some(current_phase); + previous_millis = decision.ordering_millis; + state = SccmManagementPointState::Succeeded; + classification = SccmManagementPointClassification::Success; + confidence = SccmManagementPointConfidence::High; + } + PhaseDecisionKind::Failed => { + state = SccmManagementPointState::Failed; + classification = SccmManagementPointClassification::ConfirmedFailure; + confidence = SccmManagementPointConfidence::High; + break; + } + PhaseDecisionKind::Deferred => { + state = SccmManagementPointState::Deferred; + classification = SccmManagementPointClassification::BlockedOrDeferred; + confidence = SccmManagementPointConfidence::Medium; + next_artifacts.push(workflow_request_for_group( + group_for_phase(current_phase), + deferred_reason(current_phase), + )); + break; + } + PhaseDecisionKind::Contradictory => { + state = SccmManagementPointState::Contradictory; + classification = SccmManagementPointClassification::ContradictoryEvidence; + confidence = SccmManagementPointConfidence::Low; + next_artifacts.push(workflow_request_for_group( + group_for_phase(current_phase), + contradiction_reason(current_phase), + )); + break; + } + PhaseDecisionKind::UnusableTime => { + state = SccmManagementPointState::Incomplete; + classification = SccmManagementPointClassification::InsufficientEvidence; + confidence = SccmManagementPointConfidence::Medium; + let source_group = group_for_phase(current_phase); + next_artifacts.push(workflow_request_for_group( + source_group, + timestamp_reason(current_phase), + )); + gap = Some(SccmManagementPointCoverageGap { + logical_artifact_id: source_group.to_owned(), + role: SccmRole::ManagementPoint, + state: SccmCoverageState::ParseFailed, + }); + break; + } + } + } + + let transaction_id = format!("mp:request:{}", key.request_id); + let evidence = merge_references(facts.iter().map(|fact| fact.reference.clone())); + let coverage_gap_artifact_ids = gap + .iter() + .map(|gap| gap.logical_artifact_id.clone()) + .collect::>(); + let transaction = SccmManagementPointTransaction { + transaction_id: transaction_id.clone(), + key: key.clone(), + phase, + state, + last_successful_phase, + classification, + confidence, + evidence, + observations, + coverage_gap_artifact_ids, + next_artifacts: next_artifacts.clone(), + }; + + let finding = + build_transaction_finding(&transaction, &decisive_facts, gap.as_ref(), &next_artifacts); + let counterpart_fact = + build_counterpart_fact(&transaction, &decisive_facts, &transaction_id, &key); + + Some(ReducedTransaction { + transaction, + finding, + counterpart_fact, + coverage_gap: gap, + }) +} + +fn build_transaction_key( + facts: &[ManagementPointFact], + request_fact: &ManagementPointFact, +) -> Option { + if facts.iter().any(|fact| { + fact.request_id != request_fact.request_id + || fact.client_handle != request_fact.client_handle + || fact.site_code != request_fact.site_code + || fact.management_point_host_handle != request_fact.management_point_host_handle + }) { + return None; + } + + let policy_ids = facts + .iter() + .filter_map(|fact| fact.policy_id.as_deref()) + .collect::>(); + if policy_ids.len() > 1 { + return None; + } + + Some(SccmManagementPointKey { + request_id: request_fact.request_id.clone(), + policy_id: policy_ids.first().map(|value| (*value).to_owned()), + client_handle: request_fact.client_handle.clone(), + site_code: request_fact.site_code.clone(), + management_point_host_handle: request_fact.management_point_host_handle.clone(), + confidence: SccmKeyConfidence::Exact, + extraction_profile_id: SCCM_MANAGEMENT_POINT_TEST_PROFILE_ID.to_owned(), + }) +} + +fn observation_for_fact( + request_id: &str, + index: usize, + fact: &ManagementPointFact, +) -> SccmManagementPointObservation { + let (state, classification) = match fact.outcome { + FactOutcome::Succeeded => ( + SccmManagementPointState::Succeeded, + SccmManagementPointClassification::Success, + ), + FactOutcome::Failed => ( + SccmManagementPointState::Failed, + SccmManagementPointClassification::ConfirmedFailure, + ), + FactOutcome::Deferred => ( + SccmManagementPointState::Deferred, + SccmManagementPointClassification::BlockedOrDeferred, + ), + }; + SccmManagementPointObservation { + observation_id: format!( + "observation:mp:{request_id}:{index:02}:{}", + fact.phase.serialized_name() + ), + phase: fact.phase, + state, + classification, + timestamp: fact.timestamp.clone(), + evidence: vec![fact.reference.clone()], + } +} + +fn resolve_phase<'a>(facts: &[&'a ManagementPointFact]) -> PhaseDecision<'a> { + if facts.iter().any(|fact| { + fact.timestamp.utc_millis.is_none() + || !matches!( + fact.timestamp.ordering_state, + crate::sccm::SccmTimeOrderingState::NormalizedUtc + ) + }) { + return PhaseDecision { + kind: PhaseDecisionKind::UnusableTime, + decisive: facts.to_vec(), + ordering_millis: None, + }; + } + + let Some(latest) = latest_fact(facts.iter().copied()) else { + return PhaseDecision { + kind: PhaseDecisionKind::UnusableTime, + decisive: facts.to_vec(), + ordering_millis: None, + }; + }; + let instant = latest.timestamp.utc_millis; + let same_instant = facts + .iter() + .copied() + .filter(|fact| fact.timestamp.utc_millis == instant) + .collect::>(); + if same_instant + .iter() + .any(|fact| fact.outcome != latest.outcome) + { + return PhaseDecision { + kind: PhaseDecisionKind::Contradictory, + decisive: same_instant, + ordering_millis: instant, + }; + } + + PhaseDecision { + kind: match latest.outcome { + FactOutcome::Succeeded => PhaseDecisionKind::Succeeded, + FactOutcome::Failed if latest.terminal => PhaseDecisionKind::Failed, + FactOutcome::Deferred => PhaseDecisionKind::Deferred, + FactOutcome::Failed => PhaseDecisionKind::UnusableTime, + }, + decisive: vec![latest], + ordering_millis: instant, + } +} + +fn latest_fact<'a>( + facts: impl Iterator, +) -> Option<&'a ManagementPointFact> { + facts.max_by(|left, right| { + left.timestamp + .utc_millis + .cmp(&right.timestamp.utc_millis) + .then_with(|| compare_references(&left.reference, &right.reference)) + }) +} + +fn build_transaction_finding( + transaction: &SccmManagementPointTransaction, + decisive_facts: &[&ManagementPointFact], + gap: Option<&SccmManagementPointCoverageGap>, + requests: &[SccmManagementPointArtifactRequest], +) -> Option { + if transaction.classification == SccmManagementPointClassification::Success { + return None; + } + + let evidence = if decisive_facts.is_empty() { + transaction.evidence.last().cloned().into_iter().collect() + } else { + merge_references(decisive_facts.iter().map(|fact| fact.reference.clone())) + }; + let shared_class = match transaction.classification { + SccmManagementPointClassification::ConfirmedFailure => SccmFindingClass::ConfirmedFailure, + SccmManagementPointClassification::BlockedOrDeferred => SccmFindingClass::BlockedOrDeferred, + SccmManagementPointClassification::InsufficientEvidence => { + SccmFindingClass::InsufficientEvidence + } + SccmManagementPointClassification::ContradictoryEvidence + | SccmManagementPointClassification::LowConfidenceSymptom + | SccmManagementPointClassification::IncompatibleKey + | SccmManagementPointClassification::Success => SccmFindingClass::Symptom, + }; + let shared_confidence = match transaction.confidence { + SccmManagementPointConfidence::Low => SccmConfidence::Low, + SccmManagementPointConfidence::Medium => SccmConfidence::Moderate, + SccmManagementPointConfidence::High => SccmConfidence::High, + }; + let terminal_evidence = if shared_class == SccmFindingClass::ConfirmedFailure { + decisive_facts + .iter() + .filter(|fact| fact.outcome == FactOutcome::Failed && fact.terminal) + .map(|fact| SccmTerminalEvidence::observed_failure(fact.reference.clone())) + .collect() + } else { + Vec::new() + }; + let coverage_gap = gap.map(shared_gap); + let shared_requests = requests + .iter() + .filter_map(shared_request) + .collect::>(); + + let mut builder = SccmFindingBuilder::new(format!( + "finding:mp:{}:{}", + transaction.key.request_id, + transaction.phase.serialized_name() + )) + .class(shared_class) + .phase(SccmPhase::Unknown( + transaction.phase.serialized_name().to_owned(), + )) + .role(SccmRole::ManagementPoint) + .severity(if transaction.state == SccmManagementPointState::Failed { + Severity::Error + } else { + Severity::Warning + }) + .confidence(shared_confidence) + .title("Management Point request evidence") + .summary("The Management Point request state is bounded to the cited server-local evidence.") + .evidence(evidence) + .terminal_evidence(terminal_evidence) + .next_artifacts(shared_requests); + if let Some(coverage_gap) = coverage_gap { + builder = builder.coverage_gap(coverage_gap); + } + let finding = builder.build().ok()?; + Some(SccmManagementPointFinding { + finding, + subject_id: transaction.transaction_id.clone(), + last_successful_phase: transaction.last_successful_phase, + }) +} + +fn build_counterpart_fact( + transaction: &SccmManagementPointTransaction, + decisive_facts: &[&ManagementPointFact], + transaction_id: &str, + key: &SccmManagementPointKey, +) -> Option { + if key.policy_id.is_none() + || key.confidence != SccmKeyConfidence::Exact + || transaction.confidence != SccmManagementPointConfidence::High + || !matches!( + transaction.state, + SccmManagementPointState::Succeeded | SccmManagementPointState::Failed + ) + { + return None; + } + + let fact = decisive_facts + .iter() + .copied() + .filter(|fact| fact.policy_id.as_deref() == key.policy_id.as_deref()) + .filter(|fact| fact.phase == transaction.phase) + .filter(|fact| { + matches!( + (transaction.state, fact.outcome, fact.terminal), + ( + SccmManagementPointState::Succeeded, + FactOutcome::Succeeded, + _ + ) | (SccmManagementPointState::Failed, FactOutcome::Failed, true) + ) + }) + .filter(|fact| { + matches!( + fact.timestamp.ordering_state, + crate::sccm::SccmTimeOrderingState::NormalizedUtc + ) && fact.timestamp.utc_millis.is_some() + }) + .max_by(|left, right| { + left.phase + .cmp(&right.phase) + .then_with(|| left.timestamp.utc_millis.cmp(&right.timestamp.utc_millis)) + .then_with(|| compare_references(&left.reference, &right.reference)) + })?; + + Some(SccmManagementPointCounterpartReadyFact { + transaction_id: transaction_id.to_owned(), + key: key.clone(), + phase: fact.phase, + state: transaction.state, + classification: transaction.classification, + confidence: transaction.confidence, + timestamp: fact.timestamp.clone(), + evidence: fact.reference.clone(), + terminal_evidence: (transaction.state == SccmManagementPointState::Failed) + .then(|| fact.reference.clone()), + }) +} + +fn parse_fact( + evidence: &SccmEvidence, + source: &SccmManagementPointSource, +) -> Option { + if source.artifact.configmgr_version.as_deref() != Some(MP_TEST_VERSION) + || evidence.component.as_deref() != Some(source.producer.as_str()) + { + return None; + } + let message = evidence.message.as_str(); + let (phase, outcome, terminal) = parse_phase_outcome(message, &source.producer)?; + let result = validated_result_value(message)?; + if (terminal && result.is_none_or(|value| value == 0)) + || (!terminal && result.is_some_and(|value| value != 0)) + { + return None; + } + + let request_id = normalize_uuid(&token_value(message, "RequestId")?)?; + let policy_id = match validated_token_value(message, "PolicyId")? { + Some(value) => Some(normalize_uuid(&value)?), + None => None, + }; + let client_handle = token_value(message, "ClientHandle")?; + let site_code = normalize_site_code(&token_value(message, "SiteCode")?)?; + let management_point_host_handle = token_value(message, "MPHandle")?; + if !valid_safe_handle(&client_handle, "safe:client:") + || !valid_management_point_handle(&management_point_host_handle) + { + return None; + } + + Some(ManagementPointFact { + request_id, + policy_id, + client_handle, + site_code, + management_point_host_handle, + phase, + outcome, + terminal, + reference: evidence.reference.clone(), + timestamp: evidence.timestamp.clone(), + }) +} + +fn parse_phase_outcome( + message: &str, + producer: &str, +) -> Option<(SccmManagementPointPhase, FactOutcome, bool)> { + let lowercase = event_payload(message)?.to_ascii_lowercase(); + let succeeded = FactOutcome::Succeeded; + let failed = FactOutcome::Failed; + let deferred = FactOutcome::Deferred; + + match producer { + "MP_GetAuth" if has_event_marker(&lowercase, "receive request succeeded") => { + Some((SccmManagementPointPhase::ReceiveRequest, succeeded, false)) + } + "MP_GetAuth" if has_event_marker(&lowercase, "authenticate succeeded") => { + Some((SccmManagementPointPhase::Authenticate, succeeded, false)) + } + "MP_GetAuth" if has_event_marker(&lowercase, "authenticate failed terminal") => { + Some((SccmManagementPointPhase::Authenticate, failed, true)) + } + "MP_CliReg" | "MP_RegistrationManager" + if has_event_marker(&lowercase, "register or identify succeeded") => + { + Some(( + SccmManagementPointPhase::RegisterOrIdentify, + succeeded, + false, + )) + } + "MP_CliReg" | "MP_RegistrationManager" + if has_event_marker(&lowercase, "register or identify failed terminal") => + { + Some((SccmManagementPointPhase::RegisterOrIdentify, failed, true)) + } + "MP_Location" if has_event_marker(&lowercase, "resolve location succeeded") => Some(( + SccmManagementPointPhase::ResolveLocationOrPolicy, + succeeded, + false, + )), + "MP_Location" if has_event_marker(&lowercase, "resolve location failed terminal") => { + Some(( + SccmManagementPointPhase::ResolveLocationOrPolicy, + failed, + true, + )) + } + "MP_GetPolicy" if has_event_marker(&lowercase, "resolve policy succeeded") => Some(( + SccmManagementPointPhase::ResolveLocationOrPolicy, + succeeded, + false, + )), + "MP_GetPolicy" if has_event_marker(&lowercase, "resolve policy failed terminal") => Some(( + SccmManagementPointPhase::ResolveLocationOrPolicy, + failed, + true, + )), + "MP_GetPolicy" if has_event_marker(&lowercase, "respond deferred") => { + Some((SccmManagementPointPhase::Respond, deferred, false)) + } + "MP_GetPolicy" if has_event_marker(&lowercase, "respond succeeded") => { + Some((SccmManagementPointPhase::Respond, succeeded, false)) + } + "MP_GetPolicy" if has_event_marker(&lowercase, "respond failed terminal") => { + Some((SccmManagementPointPhase::Respond, failed, true)) + } + "MP_GetPolicy" if has_event_marker(&lowercase, "record outcome succeeded") => { + Some((SccmManagementPointPhase::RecordOutcome, succeeded, false)) + } + "MP_GetPolicy" if has_event_marker(&lowercase, "record outcome failed terminal") => { + Some((SccmManagementPointPhase::RecordOutcome, failed, true)) + } + _ => None, + } +} + +fn has_event_marker(message: &str, marker: &str) -> bool { + message.strip_prefix(marker).is_some_and(|suffix| { + suffix + .chars() + .next() + .is_none_or(|character| character.is_ascii_whitespace()) + }) +} + +fn event_payload(message: &str) -> Option<&str> { + let projected = message + .strip_prefix("[sccm-public-message-v1] ") + .unwrap_or(message) + .trim_start(); + if projected.starts_with("SYNTHETIC FIXTURE ") { + return projected.split_once(": ").map(|(_, payload)| payload); + } + Some(projected) +} + +fn source_is_admitted(source: &SccmManagementPointSource) -> bool { + if source.artifact.role != SccmRole::ManagementPoint + || source.artifact.configmgr_version.as_deref() != Some(MP_TEST_VERSION) + { + return false; + } + let expected_logical_id = match (source.source_group.as_str(), source.producer.as_str()) { + (MP_AUTH_GROUP, "MP_GetAuth") => "mpGetAuth", + (MP_AUTH_GROUP, "MP_CliReg") => "mpCliReg", + (MP_AUTH_GROUP, "MP_RegistrationManager") => "mpRegistrationManager", + (MP_POLICY_GROUP, "MP_GetPolicy") => "mpGetPolicy", + (MP_POLICY_GROUP, "MP_Location") => "mpLocation", + _ => return false, + }; + let classified = + classify_artifact_name(&source.artifact.display_name, SccmRole::ManagementPoint); + classified.logical_name == expected_logical_id + && classified.family == SccmArtifactFamily::ManagementPoint + && classified.supported_for_diagnosis + && classified.rotation == source.artifact.rotation +} + +/// Supplemental sources are subject-scoped inputs, never MP-produced +/// evidence. `mpcontrol.log` is produced by the site-server MP control +/// workflow about the Management Point, so it is only supplemental when +/// the bundle models it under the site-server producer role declared in +/// the shared catalog; an MP-produced claim fails closed as rejected +/// evidence instead. +fn is_supplemental_source(source: &SccmManagementPointSource) -> bool { + if source.source_group == MP_IIS_GROUP { + return true; + } + if source.source_group != MP_POLICY_GROUP || source.artifact.role != SccmRole::SiteServer { + return false; + } + let classified = classify_artifact_name(&source.artifact.display_name, SccmRole::SiteServer); + classified.logical_name == "mpcontrol" + && classified.family == SccmArtifactFamily::ManagementPoint +} + +fn validated_token_value(message: &str, label: &str) -> Option> { + let lowercase = message.to_ascii_lowercase(); + let needle = format!("{}=", label.to_ascii_lowercase()); + let mut value = None; + for (label_start, _) in lowercase.match_indices(&needle) { + let exact_label_boundary = label_start == 0 + || message[..label_start] + .chars() + .next_back() + .is_some_and(is_key_token_boundary); + if !exact_label_boundary { + return None; + } + + let remainder = &message[label_start + needle.len()..]; + let parsed = if let Some(braced) = remainder.strip_prefix('{') { + let end = braced.find('}')?; + let suffix = &braced[end + 1..]; + let exact_value_boundary = suffix.chars().next().is_none_or(is_key_token_boundary); + if !exact_value_boundary || end == 0 { + return None; + } + braced[..end].to_owned() + } else { + let end = remainder + .find(is_key_token_boundary) + .unwrap_or(remainder.len()); + if end == 0 { + return None; + } + remainder[..end].to_owned() + }; + if value.replace(parsed).is_some() { + return None; + } + } + Some(value) +} + +fn is_key_token_boundary(character: char) -> bool { + character.is_whitespace() || matches!(character, ',' | ';' | '&') +} + +fn token_value(message: &str, label: &str) -> Option { + validated_token_value(message, label)? +} + +fn normalize_uuid(value: &str) -> Option { + let bytes = value.as_bytes(); + let valid = bytes.len() == 36 + && bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) + && bytes + .iter() + .any(|byte| byte.is_ascii_hexdigit() && *byte != b'0'); + valid.then(|| value.to_ascii_lowercase()) +} + +fn validated_result_value(message: &str) -> Option> { + let Some(value) = validated_token_value(message, "Result")? else { + return Some(None); + }; + let hex = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X"))?; + (hex.len() == 8 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| u32::from_str_radix(hex, 16).ok()) + .flatten() + .map(Some) +} + +fn normalize_site_code(value: &str) -> Option { + (value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_alphanumeric())) + .then(|| value.to_ascii_uppercase()) +} + +fn valid_safe_handle(value: &str, prefix: &str) -> bool { + let Some(payload) = value.strip_prefix(prefix) else { + return false; + }; + !payload.is_empty() + && payload.len() <= 128 + && payload + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && payload + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + && !payload.contains("..") + && payload + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn valid_management_point_handle(value: &str) -> bool { + valid_safe_handle(value, "safe:mp:") + || value == "synthetic:host:mp-01" + || opaque_host_handle(value) +} + +fn opaque_host_handle(value: &str) -> bool { + value + .strip_prefix("cmtraceopen.host.sha256.v1:") + .is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +#[cfg(test)] +#[path = "management_point_tests.rs"] +mod management_point_tests; + +fn safe_opaque_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':')) +} + +fn artifact_id_is_unique(bundle: &SccmManagementPointBundle, artifact_id: &str) -> bool { + bundle + .sources + .iter() + .filter(|source| source.artifact.artifact_id == artifact_id) + .take(2) + .count() + == 1 +} + +fn evidence_identity_is_unique( + bundle: &SccmManagementPointBundle, + evidence: &SccmEvidence, +) -> bool { + bundle + .evidence + .iter() + .filter(|candidate| { + candidate.evidence_id == evidence.evidence_id + || candidate.reference.entry_id == evidence.reference.entry_id + || evidence_references_overlap(&candidate.reference, &evidence.reference) + }) + .take(2) + .count() + == 1 +} + +fn safe_evidence_reference(reference: &SccmEvidenceRef) -> bool { + safe_opaque_id(&reference.artifact_id) + && safe_opaque_id(&reference.entry_id) + && matches!( + (reference.line_start, reference.line_end), + (Some(start), Some(end)) if start > 0 && end >= start + ) +} + +fn evidence_reference_fits_source( + evidence: &SccmEvidence, + source: &SccmManagementPointSource, +) -> bool { + safe_evidence_reference(&evidence.reference) + && evidence.evidence_id == evidence.reference.entry_id + && source.physical_line_end.is_some_and(|physical_end| { + physical_end > 0 + && evidence + .reference + .line_end + .is_some_and(|line_end| line_end <= physical_end) + }) +} + +fn sort_facts(facts: &mut [ManagementPointFact]) { + facts.sort_by(|left, right| { + left.phase + .cmp(&right.phase) + .then_with(|| left.timestamp.utc_millis.cmp(&right.timestamp.utc_millis)) + .then_with(|| compare_references(&left.reference, &right.reference)) + }); +} + +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)) +} + +fn merge_references(references: impl IntoIterator) -> Vec { + let mut references = references + .into_iter() + .filter(|reference| { + matches!( + (reference.line_start, reference.line_end), + (Some(start), Some(end)) if start > 0 && end >= start + ) + }) + .collect::>(); + references.sort_by(compare_references); + references.dedup(); + references +} + +fn physical_reference(source: &SccmManagementPointSource) -> Option { + let line_end = source.physical_line_end?; + (line_end > 0 && safe_opaque_id(&source.artifact.artifact_id)).then(|| SccmEvidenceRef { + artifact_id: source.artifact.artifact_id.clone(), + entry_id: format!("{}:physical:1-{line_end}", source.artifact.artifact_id), + line_start: Some(1), + line_end: Some(line_end), + }) +} + +fn append_rotation_fragment_observation( + bundle: &SccmManagementPointBundle, + observations: &mut Vec, + findings: &mut Vec, + coverage_gaps: &mut Vec, + consumed_gap_groups: &mut BTreeSet, +) { + let evidence = merge_references( + bundle + .sources + .iter() + .filter(|source| { + source.artifact.role == SccmRole::ManagementPoint + && source.source_group == MP_AUTH_GROUP + && source.artifact.coverage == SccmCoverageState::Captured + && source.fragment_complete == Some(false) + }) + .filter_map(physical_reference), + ); + if evidence.is_empty() { + return; + } + + let request = workflow_request_for_group( + MP_AUTH_GROUP, + "Collect a complete bounded MP_GetAuth.log record; physical rotation fragments are coverage-only.", + ); + observations.push(SccmManagementPointSourceLocalObservation { + observation_id: "observation:rotation-fragments".to_owned(), + phase: SccmManagementPointPhase::ReceiveRequest, + state: SccmManagementPointState::Incomplete, + classification: SccmManagementPointClassification::InsufficientEvidence, + confidence: SccmManagementPointConfidence::Low, + correlation_eligible: false, + evidence: evidence.clone(), + next_artifacts: vec![request.clone()], + }); + let gap = SccmManagementPointCoverageGap { + logical_artifact_id: MP_AUTH_GROUP.to_owned(), + role: SccmRole::ManagementPoint, + state: SccmCoverageState::ParseFailed, + }; + if let Some(finding) = build_source_local_finding( + "finding:mp-rotation-fragments", + "observation:rotation-fragments", + SccmManagementPointPhase::ReceiveRequest, + SccmManagementPointClassification::InsufficientEvidence, + evidence, + Some(&gap), + &[request], + ) { + findings.push(finding); + } + coverage_gaps.push(gap); + consumed_gap_groups.insert(MP_AUTH_GROUP.to_owned()); +} + +/// Rejected records surface one observation per owning source group so +/// each remediation hint requests the group the rejected record actually +/// belongs to instead of defaulting to the authentication family. +fn append_rejected_observations( + bundle: &SccmManagementPointBundle, + rejected: &mut Vec, + observations: &mut Vec, + findings: &mut Vec, +) { + rejected.sort_by(compare_references); + rejected.dedup(); + if rejected.is_empty() { + return; + } + + let rotation = bundle + .sources + .iter() + .any(|source| source.fragment_complete == Some(false)); + let mut references_by_group: BTreeMap<&'static str, Vec> = BTreeMap::new(); + for reference in rejected.iter() { + references_by_group + .entry(owning_group_for_reference(bundle, reference)) + .or_default() + .push(reference.clone()); + } + + for (group, references) in references_by_group { + let unrelated = references.iter().any(|reference| { + bundle + .evidence + .iter() + .filter(|evidence| evidence.reference == *reference) + .any(|evidence| { + token_value(&evidence.message, "RequestId").is_none() + && (token_value(&evidence.message, "AssignmentId").is_some() + || token_value(&evidence.message, "ClientId").is_some()) + }) + }); + let rejection = if unrelated { + MpRejection::UnrelatedClientLikeKey + } else if rotation { + MpRejection::RotationMalformed + } else { + MpRejection::Malformed + }; + let phase = entry_phase_for_group(group); + let classification = rejection.classification(); + let observation_id = format!("observation:{}:{group}", rejection.id_stem()); + let request = workflow_request_for_group(group, &rejection.reason(group)); + let evidence = merge_references(references); + observations.push(SccmManagementPointSourceLocalObservation { + observation_id: observation_id.clone(), + phase, + state: SccmManagementPointState::Observed, + classification, + confidence: SccmManagementPointConfidence::Low, + correlation_eligible: false, + evidence: evidence.clone(), + next_artifacts: vec![request.clone()], + }); + if let Some(finding) = build_source_local_finding( + &format!("finding:{}:{group}", rejection.id_stem()), + &observation_id, + phase, + classification, + evidence, + None, + &[request], + ) { + findings.push(finding); + } + } +} + +/// Why a Management Point record was rejected. The variant fixes the +/// observation identity and classification; the owning source group fixes +/// the phase and the artifact the remediation hint requests. +#[derive(Clone, Copy)] +enum MpRejection { + UnrelatedClientLikeKey, + RotationMalformed, + Malformed, +} + +impl MpRejection { + fn id_stem(self) -> &'static str { + match self { + Self::UnrelatedClientLikeKey => "unrelated-client-like-key", + Self::RotationMalformed => "rotation-malformed", + Self::Malformed => "mp-malformed", + } + } + + fn classification(self) -> SccmManagementPointClassification { + match self { + Self::UnrelatedClientLikeKey => SccmManagementPointClassification::IncompatibleKey, + Self::RotationMalformed | Self::Malformed => { + SccmManagementPointClassification::LowConfidenceSymptom + } + } + } + + fn reason(self, group: &str) -> String { + let log = workflow_log_for_group(group); + match self { + Self::UnrelatedClientLikeKey => { + format!("Capture bounded {log} evidence with the exact versioned request key.") + } + Self::RotationMalformed => format!( + "Collect a supported-version {log} record containing a complete exact request key." + ), + Self::Malformed => { + format!("Collect bounded {log} evidence under the validated extraction profile.") + } + } + } +} + +/// The source group that owns a rejected reference. Supplemental and +/// unknown owners keep the authentication-family default because that is +/// the only remaining group able to carry the request key the rejected +/// record failed to produce. `any` rather than `find` keeps the answer +/// independent of source ordering when an artifact id is duplicated. +fn owning_group_for_reference( + bundle: &SccmManagementPointBundle, + reference: &SccmEvidenceRef, +) -> &'static str { + let policy_owned = bundle.sources.iter().any(|source| { + source.artifact.artifact_id == reference.artifact_id + && source.source_group == MP_POLICY_GROUP + }); + if policy_owned { + MP_POLICY_GROUP + } else { + MP_AUTH_GROUP + } +} + +/// The phase a source group answers for: the inverse of [`group_for_phase`], +/// so an observation never cites a phase that maps back to another group. +fn entry_phase_for_group(group: &str) -> SccmManagementPointPhase { + if group == MP_POLICY_GROUP { + SccmManagementPointPhase::ResolveLocationOrPolicy + } else { + SccmManagementPointPhase::ReceiveRequest + } +} + +fn workflow_log_for_group(group: &str) -> &'static str { + if group == MP_POLICY_GROUP { + "MP_GetPolicy.log" + } else { + "MP_GetAuth.log" + } +} + +fn append_unconsumed_explicit_coverage( + bundle: &SccmManagementPointBundle, + observations: &mut Vec, + findings: &mut Vec, + coverage_gaps: &mut Vec, + consumed_gap_groups: &mut BTreeSet, +) { + for group in [MP_AUTH_GROUP, MP_POLICY_GROUP] { + if consumed_gap_groups.contains(group) { + continue; + } + let sources = bundle + .sources + .iter() + .filter(|source| source.source_group == group) + .collect::>(); + if sources.is_empty() + || sources.iter().any(|source| { + source.artifact.coverage == SccmCoverageState::Captured + && source.fragment_complete == Some(true) + && source + .physical_line_end + .is_some_and(|line_end| line_end > 0) + && artifact_id_is_unique(bundle, &source.artifact.artifact_id) + && source_is_admitted(source) + }) + { + continue; + } + if observations.iter().any(|observation| { + observation + .next_artifacts + .iter() + .any(|request| request.logical_artifact_id == group) + }) { + continue; + } + + let state = coverage_for_group(bundle, group); + let request = workflow_request_for_group( + group, + if group == MP_AUTH_GROUP { + "Capture bounded MP_GetAuth.log coverage before evaluating Management Point requests." + } else { + "Capture bounded MP_GetPolicy.log coverage before evaluating later Management Point phases." + }, + ); + let observation_id = format!("observation:coverage:{group}"); + observations.push(SccmManagementPointSourceLocalObservation { + observation_id: observation_id.clone(), + phase: entry_phase_for_group(group), + state: SccmManagementPointState::Incomplete, + classification: SccmManagementPointClassification::InsufficientEvidence, + confidence: SccmManagementPointConfidence::Low, + correlation_eligible: false, + evidence: Vec::new(), + next_artifacts: vec![request.clone()], + }); + let gap = SccmManagementPointCoverageGap { + logical_artifact_id: group.to_owned(), + role: SccmRole::ManagementPoint, + state, + }; + if let Some(finding) = build_source_local_finding( + &format!("finding:mp-coverage:{group}"), + &observation_id, + entry_phase_for_group(group), + SccmManagementPointClassification::InsufficientEvidence, + Vec::new(), + Some(&gap), + &[request], + ) { + findings.push(finding); + } + coverage_gaps.push(gap); + consumed_gap_groups.insert(group.to_owned()); + } +} + +fn build_source_local_finding( + finding_id: &str, + subject_id: &str, + phase: SccmManagementPointPhase, + classification: SccmManagementPointClassification, + evidence: Vec, + gap: Option<&SccmManagementPointCoverageGap>, + requests: &[SccmManagementPointArtifactRequest], +) -> Option { + let class = if classification == SccmManagementPointClassification::InsufficientEvidence { + SccmFindingClass::InsufficientEvidence + } else { + SccmFindingClass::Symptom + }; + let mut builder = SccmFindingBuilder::new(finding_id) + .class(class) + .phase(SccmPhase::Unknown(phase.serialized_name().to_owned())) + .role(SccmRole::ManagementPoint) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .title("Management Point source-local evidence") + .summary("The observation is source-local and is not eligible for cross-side correlation.") + .evidence(evidence) + .next_artifacts( + requests + .iter() + .filter_map(shared_request) + .collect::>(), + ); + if let Some(gap) = gap { + builder = builder.coverage_gap(shared_gap(gap)); + } + Some(SccmManagementPointFinding { + finding: builder.build().ok()?, + subject_id: subject_id.to_owned(), + last_successful_phase: None, + }) +} + +fn workflow_request_for_group(group: &str, reason: &str) -> SccmManagementPointArtifactRequest { + SccmManagementPointArtifactRequest { + logical_artifact_id: group.to_owned(), + reason: reason.to_owned(), + } +} + +fn shared_request(request: &SccmManagementPointArtifactRequest) -> Option { + let logical_id = match request.logical_artifact_id.as_str() { + MP_AUTH_GROUP => "mpGetAuth", + MP_POLICY_GROUP => "mpGetPolicy", + _ => return None, + }; + Some(SccmArtifactRequest { + logical_id: logical_id.to_owned(), + role: SccmRole::ManagementPoint, + reason: match request.logical_artifact_id.as_str() { + MP_AUTH_GROUP => "Collect the complete MP_GetAuth.log file.", + MP_POLICY_GROUP => "Collect the complete MP_GetPolicy.log file.", + _ => return None, + } + .to_owned(), + }) +} + +fn shared_gap(gap: &SccmManagementPointCoverageGap) -> SccmFindingCoverageGap { + SccmFindingCoverageGap { + artifact_id: gap.logical_artifact_id.clone(), + role: gap.role.clone(), + coverage: gap.state.clone(), + } +} + +fn group_for_phase(phase: SccmManagementPointPhase) -> &'static str { + match phase { + SccmManagementPointPhase::ReceiveRequest + | SccmManagementPointPhase::Authenticate + | SccmManagementPointPhase::RegisterOrIdentify => MP_AUTH_GROUP, + SccmManagementPointPhase::ResolveLocationOrPolicy + | SccmManagementPointPhase::Respond + | SccmManagementPointPhase::RecordOutcome => MP_POLICY_GROUP, + } +} + +fn missing_phase_reason(phase: SccmManagementPointPhase) -> &'static str { + match phase { + SccmManagementPointPhase::ReceiveRequest + | SccmManagementPointPhase::Authenticate + | SccmManagementPointPhase::RegisterOrIdentify => { + "Capture bounded MP_GetAuth.log evidence before evaluating the missing authentication-family phase." + } + SccmManagementPointPhase::ResolveLocationOrPolicy + | SccmManagementPointPhase::Respond + | SccmManagementPointPhase::RecordOutcome => { + "Capture bounded MP_GetPolicy.log evidence before evaluating the missing policy-family phase." + } + } +} + +fn contradiction_reason(phase: SccmManagementPointPhase) -> &'static str { + match group_for_phase(phase) { + MP_AUTH_GROUP => { + "Recapture bounded MP_GetAuth.log evidence to resolve the same-instant authentication-family contradiction." + } + _ => { + "Recapture bounded MP_GetPolicy.log evidence to resolve the same-instant policy-family contradiction." + } + } +} + +fn chronology_reason(phase: SccmManagementPointPhase) -> &'static str { + match group_for_phase(phase) { + MP_AUTH_GROUP => { + "Recapture bounded MP_GetAuth.log evidence with usable ordering provenance for this phase." + } + _ => { + "Recapture bounded MP_GetPolicy.log evidence with usable ordering provenance for this phase." + } + } +} + +fn timestamp_reason(phase: SccmManagementPointPhase) -> &'static str { + chronology_reason(phase) +} + +fn deferred_reason(phase: SccmManagementPointPhase) -> &'static str { + match group_for_phase(phase) { + MP_AUTH_GROUP => { + "Capture a later bounded MP_GetAuth.log terminal outcome for the same exact request key." + } + _ => { + "Capture a later bounded MP_GetPolicy.log terminal outcome for the same exact request key." + } + } +} + +fn coverage_for_group(bundle: &SccmManagementPointBundle, group: &str) -> SccmCoverageState { + let states = bundle + .sources + .iter() + .filter(|source| source.source_group == group && !is_supplemental_source(source)) + .map(|source| { + if source.artifact.coverage == SccmCoverageState::Captured + && (source.fragment_complete != Some(true) + || source + .physical_line_end + .is_none_or(|line_end| line_end == 0)) + { + SccmCoverageState::ParseFailed + } else { + source.artifact.coverage.clone() + } + }) + .collect::>(); + for preferred in [ + SccmCoverageState::AccessDenied, + SccmCoverageState::Capped, + SccmCoverageState::ParseFailed, + SccmCoverageState::Unsupported, + SccmCoverageState::Skipped, + SccmCoverageState::Absent, + ] { + if states.contains(&preferred) { + return preferred; + } + } + if states.contains(&SccmCoverageState::Captured) { + return SccmCoverageState::ParseFailed; + } + SccmCoverageState::Absent +} + +fn compare_observations( + left: &SccmManagementPointObservation, + right: &SccmManagementPointObservation, +) -> Ordering { + left.phase + .cmp(&right.phase) + .then_with(|| left.timestamp.utc_millis.cmp(&right.timestamp.utc_millis)) + .then_with(|| left.observation_id.cmp(&right.observation_id)) +} + +fn normalize_analysis( + transactions: &mut [SccmManagementPointTransaction], + observations: &mut [SccmManagementPointSourceLocalObservation], + findings: &mut [SccmManagementPointFinding], + coverage_gaps: &mut Vec, + counterpart_facts: &mut [SccmManagementPointCounterpartReadyFact], +) { + transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_id)); + observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + findings.sort_by(|left, right| { + left.subject_id + .cmp(&right.subject_id) + .then_with(|| left.finding.finding_id.cmp(&right.finding.finding_id)) + }); + coverage_gaps.sort_by(|left, right| { + left.logical_artifact_id + .cmp(&right.logical_artifact_id) + .then_with(|| coverage_order(&left.state).cmp(&coverage_order(&right.state))) + }); + coverage_gaps.dedup_by(|right, left| { + right.logical_artifact_id == left.logical_artifact_id + && right.role == left.role + && right.state == left.state + }); + counterpart_facts.sort_by(|left, right| { + left.transaction_id + .cmp(&right.transaction_id) + .then_with(|| compare_references(&left.evidence, &right.evidence)) + }); +} + +fn collect_artifact_requests( + transactions: &[SccmManagementPointTransaction], + observations: &[SccmManagementPointSourceLocalObservation], +) -> Vec { + let mut requests = transactions + .iter() + .flat_map(|transaction| transaction.next_artifacts.iter()) + .chain( + observations + .iter() + .flat_map(|observation| observation.next_artifacts.iter()), + ) + .cloned() + .collect::>(); + requests.sort_by(|left, right| { + left.logical_artifact_id + .cmp(&right.logical_artifact_id) + .then_with(|| left.reason.cmp(&right.reason)) + }); + requests.dedup(); + requests +} + +fn coverage_order(state: &SccmCoverageState) -> u8 { + match state { + SccmCoverageState::Captured => 0, + SccmCoverageState::Absent => 1, + SccmCoverageState::AccessDenied => 2, + SccmCoverageState::Capped => 3, + SccmCoverageState::Skipped => 4, + SccmCoverageState::Unsupported => 5, + SccmCoverageState::ParseFailed => 6, + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point_tests.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point_tests.rs new file mode 100644 index 000000000..5f301867f --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point_tests.rs @@ -0,0 +1,2208 @@ +use super::canonical_fragment_complete; +use crate as cmtraceopen_parser; +use crate::sccm::server::windows::intake::{ + intake_integrity_work_probe, reset_intake_integrity_work_probe, +}; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::server::windows::{ + analyze_management_point_fixture, analyze_management_point_from_server_intake, + assess_server_intake, declared_server_source_catalog, SccmManagementPointBundle, + SccmManagementPointIntakeError, SccmManagementPointSource, SccmManagementPointTopology, + SccmServerArtifactPayload, +}; +use cmtraceopen_parser::sccm::{ + declared_source_catalog, normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, + SccmCoverageState, SccmRole, SccmRotation, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +const FIXTURE_ROOT: &str = "tests/fixtures/sccm/server/management-point"; +const SCENARIOS: &[&str] = &[ + "healthy-policy", + "auth-failure", + "registration-failure", + "location-failure", + "policy-failure", + "iis-supplemental", + "unrelated-client-like-key", + "rotation-boundary", + "incomplete", +]; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureManifest { + topology: FixtureTopology, + artifacts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureTopology { + site_code: String, + management_point_host_handle: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureArtifact { + artifact_id: String, + design_only_catalog: FixtureCatalog, + role: String, + producer: String, + capture_state: String, + original_basename: String, + rotation: FixtureRotation, + source_version: Option, + collected_utc: Option, + encoding: Option, + relative_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureCatalog { + entry_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureRotation { + kind: String, + value: Option, + fragment_complete: Option, +} + +fn fixture_directory(scenario: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join(FIXTURE_ROOT) + .join(scenario) +} + +fn load_json(path: &Path) -> Value { + serde_json::from_str(&fs::read_to_string(path).expect("fixture JSON must be readable")) + .expect("fixture JSON must be valid") +} + +fn coverage_state(value: &str) -> SccmCoverageState { + match value { + "captured" => SccmCoverageState::Captured, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "capped" => SccmCoverageState::Capped, + "skipped" => SccmCoverageState::Skipped, + "unsupported" => SccmCoverageState::Unsupported, + "parseFailed" => SccmCoverageState::ParseFailed, + other => panic!("unsupported fixture coverage state {other}"), + } +} + +fn rotation(value: &FixtureRotation) -> SccmRotation { + match value.kind.as_str() { + "current" => SccmRotation::Current, + "lo" | "loUnderscore" => SccmRotation::LoUnderscore, + "numbered" => SccmRotation::Numbered( + value + .value + .as_ref() + .and_then(Value::as_u64) + .and_then(|number| u32::try_from(number).ok()) + .expect("numbered rotation must contain a u32"), + ), + "timestamped" => SccmRotation::Timestamped( + value + .value + .as_ref() + .and_then(Value::as_str) + .expect("timestamped rotation must contain a string") + .to_owned(), + ), + other => panic!("unsupported fixture rotation {other}"), + } +} + +fn load_bundle(scenario: &str) -> SccmManagementPointBundle { + let directory = fixture_directory(scenario); + let manifest: FixtureManifest = + serde_json::from_value(load_json(&directory.join("manifest.json"))) + .expect("fixture manifest must match its declared contract"); + + let mut sources = Vec::new(); + let mut evidence = Vec::new(); + for source in manifest.artifacts { + let producer_role = match source.role.as_str() { + "managementPoint" => SccmRole::ManagementPoint, + "siteServer" => SccmRole::SiteServer, + other => panic!("unsupported MP fixture producer role {other}"), + }; + let artifact = SccmArtifact { + artifact_id: source.artifact_id, + display_name: source.original_basename, + original_path: None, + host: None, + role: producer_role, + configmgr_version: source.source_version, + collected_at_utc: source.collected_utc, + rotation: rotation(&source.rotation), + coverage: coverage_state(&source.capture_state), + encoding: source.encoding, + }; + + let physical_line_end = if let Some(relative_path) = source.relative_path { + let content = fs::read_to_string(directory.join(relative_path)) + .expect("captured MP evidence must be readable UTF-8"); + let line_count = u32::try_from(content.lines().count()) + .expect("synthetic fixture line count must fit in u32"); + evidence.extend(normalize_ccm_artifact(artifact.clone(), &content)); + Some(line_count.max(1)) + } else { + None + }; + + sources.push(SccmManagementPointSource { + artifact, + source_group: source.design_only_catalog.entry_id, + producer: source.producer, + fragment_complete: source.rotation.fragment_complete, + physical_line_end, + }); + } + + sources.sort_by(|left, right| left.artifact.artifact_id.cmp(&right.artifact.artifact_id)); + evidence.sort_by(|left, right| left.evidence_id.cmp(&right.evidence_id)); + SccmManagementPointBundle { + topology: SccmManagementPointTopology { + site_code: manifest.topology.site_code, + management_point_host_handle: manifest.topology.management_point_host_handle, + }, + sources, + evidence, + } +} + +fn load_server_intake_fixture( + directory: &Path, +) -> cmtraceopen_parser::sccm::server::windows::SccmServerIntakeAssessment { + let manifest = load_json(&directory.join("manifest.json")); + assess_server_intake_manifest(directory, &manifest) +} + +fn assess_server_intake_manifest( + directory: &Path, + manifest: &Value, +) -> cmtraceopen_parser::sccm::server::windows::SccmServerIntakeAssessment { + assess_server_intake_manifest_with_payload_manifest(directory, manifest, manifest) +} + +fn assess_server_intake_manifest_with_payload_manifest( + directory: &Path, + manifest: &Value, + payload_manifest: &Value, +) -> cmtraceopen_parser::sccm::server::windows::SccmServerIntakeAssessment { + let payloads = payload_manifest["artifacts"] + .as_array() + .expect("canonical MP fixture artifacts") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"] + .as_str() + .expect("server intake fixture artifact ID") + .to_owned(), + bytes: fs::read(directory.join(relative_path)) + .expect("server intake fixture payload must be readable"), + }) + }) + .collect::>(); + assess_server_intake( + &serde_json::to_string(manifest).expect("server intake fixture manifest serializes"), + &payloads, + ) + .expect("fixture must satisfy canonical server intake") +} + +fn load_canonical_intake( + scenario: &str, +) -> cmtraceopen_parser::sccm::server::windows::SccmServerIntakeAssessment { + load_server_intake_fixture(&fixture_directory(scenario)) +} + +fn load_server_intake_scenario( + scenario: &str, +) -> cmtraceopen_parser::sccm::server::windows::SccmServerIntakeAssessment { + load_server_intake_fixture( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/intake") + .join(scenario), + ) +} + +fn assert_unbound_intake_projection( + assessment: &cmtraceopen_parser::sccm::server::windows::SccmServerIntakeAssessment, + context: &str, +) { + let actual = analyze_management_point_from_server_intake(assessment); + assert!( + matches!( + &actual, + Err(SccmManagementPointIntakeError::SourceMismatch { artifact_id }) + if artifact_id == "management-point-intake-projection" + ), + "{context}: {actual:?}" + ); +} + +fn expected_transaction_projection(expected: &Value) -> Vec { + expected["transactions"] + .as_array() + .expect("expected transactions") + .iter() + .map(|transaction| { + let next_artifact_logical_ids = transaction["nextArtifact"]["logicalArtifactId"] + .as_str() + .map(|_| vec![transaction["nextArtifact"]["logicalArtifactId"].clone()]) + .unwrap_or_default(); + json!({ + "transactionId": transaction["transactionId"], + "phase": transaction["phase"], + "state": transaction["state"], + "lastSuccessfulPhase": transaction["lastSuccessfulPhase"], + "classification": transaction["classification"], + "confidence": transaction["confidence"], + "coverageGapArtifactIds": transaction["coverageGapArtifactIds"], + "nextArtifactLogicalIds": next_artifact_logical_ids, + }) + }) + .collect() +} + +fn actual_transaction_projection(analysis: &Value) -> Vec { + analysis["transactions"] + .as_array() + .expect("analysis transactions") + .iter() + .map(|transaction| { + let next_artifact_logical_ids = transaction["nextArtifacts"] + .as_array() + .map(|requests| { + requests + .iter() + .map(|request| request["logicalArtifactId"].clone()) + .collect::>() + }) + .unwrap_or_default(); + json!({ + "transactionId": transaction["transactionId"], + "phase": transaction["phase"], + "state": transaction["state"], + "lastSuccessfulPhase": transaction["lastSuccessfulPhase"], + "classification": transaction["classification"], + "confidence": transaction["confidence"], + "coverageGapArtifactIds": transaction["coverageGapArtifactIds"], + "nextArtifactLogicalIds": next_artifact_logical_ids, + }) + }) + .collect() +} + +fn reference_is_within_expected_ranges(reference: &Value, expected_ranges: &[Value]) -> bool { + let Some(artifact_id) = reference["artifactId"].as_str() else { + return false; + }; + let Some(line_start) = reference["lineStart"].as_u64() else { + return false; + }; + let Some(line_end) = reference["lineEnd"].as_u64() else { + return false; + }; + + expected_ranges.iter().any(|expected_reference| { + expected_reference["artifactId"].as_str() == Some(artifact_id) + && expected_reference["startLine"] + .as_u64() + .is_some_and(|start| start <= line_start) + && expected_reference["endLine"] + .as_u64() + .is_some_and(|end| line_end <= end) + }) +} + +fn assert_transaction_contract(scenario: &str, analysis: &Value, expected: &Value) { + let actual_by_id = analysis["transactions"] + .as_array() + .expect("analysis transactions") + .iter() + .map(|transaction| { + ( + transaction["transactionId"] + .as_str() + .expect("transaction ID"), + transaction, + ) + }) + .collect::>(); + + for expected_transaction in expected["transactions"] + .as_array() + .expect("expected transactions") + { + let transaction_id = expected_transaction["transactionId"] + .as_str() + .expect("expected transaction ID"); + let actual = actual_by_id + .get(transaction_id) + .unwrap_or_else(|| panic!("{scenario}: missing transaction {transaction_id}")); + let expected_key = &expected_transaction["key"]; + assert_eq!( + actual["key"], + json!({ + "requestId": expected_key["requestId"], + "policyId": expected_key["policyId"], + "clientHandle": expected_key["clientHandle"], + "siteCode": expected_key["siteCode"], + "managementPointHostHandle": expected_key["managementPointHostHandle"], + "confidence": expected_key["confidence"], + "extractionProfileId": expected_key["extractionProfileId"], + }), + "{scenario}: {transaction_id} key" + ); + + let expected_ranges = expected_transaction["evidence"] + .as_array() + .expect("expected transaction evidence"); + let actual_references = actual["evidence"] + .as_array() + .expect("analysis transaction evidence"); + assert!( + actual_references + .iter() + .all(|reference| reference_is_within_expected_ranges(reference, expected_ranges)), + "{scenario}: {transaction_id} emitted uncited evidence" + ); + for expected_reference in expected_ranges { + assert!( + actual_references.iter().any(|reference| { + reference_is_within_expected_ranges( + reference, + std::slice::from_ref(expected_reference), + ) + }), + "{scenario}: {transaction_id} omitted an expected evidence range" + ); + } + + let observations = actual["observations"] + .as_array() + .expect("transaction observations"); + assert_eq!( + observations.len(), + expected_transaction["observations"] + .as_array() + .expect("expected transaction observations") + .len(), + "{scenario}: observation count" + ); + assert!(observations.iter().all(|observation| { + observation["evidence"] + .as_array() + .is_some_and(|references| !references.is_empty()) + })); + } +} + +fn source_local_projection(value: &Value, actual: bool) -> Vec { + value["sourceLocalObservations"] + .as_array() + .expect("source-local observations") + .iter() + .map(|observation| { + let next_logical_ids = if actual { + observation["nextArtifacts"] + .as_array() + .map(|requests| { + requests + .iter() + .map(|request| request["logicalArtifactId"].clone()) + .collect::>() + }) + .unwrap_or_default() + } else { + observation["nextArtifact"]["logicalArtifactId"] + .as_str() + .map(|_| vec![observation["nextArtifact"]["logicalArtifactId"].clone()]) + .unwrap_or_default() + }; + json!({ + "observationId": observation["observationId"], + "phase": observation["phase"], + "classification": observation["classification"], + "confidence": observation["confidence"], + "correlationEligible": observation["correlationEligible"], + "nextArtifactLogicalIds": next_logical_ids, + }) + }) + .collect() +} + +fn map_shared_request_to_group(logical_id: &str) -> Option<&'static str> { + match logical_id { + "mpCliReg" | "mpGetAuth" | "mpRegistrationManager" => Some("server-mp-auth"), + "mpGetPolicy" | "mpLocation" => Some("server-mp-policy"), + _ => None, + } +} + +fn expected_finding_signatures(expected: &Value) -> Vec { + let mut signatures = expected["findings"] + .as_array() + .expect("expected findings") + .iter() + .map(|finding| { + let class = match finding["class"].as_str().expect("finding class") { + "contradictoryEvidence" | "lowConfidenceSymptom" => "symptom", + class => class, + }; + let confidence = match finding["confidence"].as_str().expect("finding confidence") { + "medium" => "moderate", + confidence => confidence, + }; + json!({ + "subjectId": finding["subjectId"], + "class": class, + "phase": finding["phase"], + "lastSuccessfulPhase": finding["lastSuccessfulPhase"], + "confidence": confidence, + "nextArtifactGroup": finding["nextArtifact"]["logicalArtifactId"], + }) + }) + .collect::>(); + signatures.sort_by_key(Value::to_string); + signatures +} + +fn actual_finding_signatures(analysis: &Value) -> Vec { + let mut signatures = analysis["findings"] + .as_array() + .expect("analysis findings") + .iter() + .map(|finding| { + let request_groups = finding["nextArtifacts"] + .as_array() + .expect("finding requests") + .iter() + .filter_map(|request| { + request["logicalId"] + .as_str() + .and_then(map_shared_request_to_group) + }) + .collect::>(); + assert!( + request_groups.len() <= 1, + "one MP finding requested unrelated source groups" + ); + json!({ + "subjectId": finding["subjectId"], + "class": finding["class"], + "phase": finding["phase"], + "lastSuccessfulPhase": finding["lastSuccessfulPhase"], + "confidence": finding["confidence"], + "nextArtifactGroup": request_groups.first().copied(), + }) + }) + .collect::>(); + signatures.sort_by_key(Value::to_string); + signatures +} + +fn assert_findings_are_cited_and_conservative(analysis: &Value) { + for finding in analysis["findings"].as_array().expect("analysis findings") { + assert_eq!(finding["role"], "managementPoint"); + let evidence = finding["evidence"].as_array().expect("finding evidence"); + let terminal = finding["terminalEvidence"] + .as_array() + .expect("terminal evidence"); + let gaps = finding["coverageGaps"] + .as_array() + .expect("finding coverage gaps"); + let requests = finding["nextArtifacts"] + .as_array() + .expect("finding requests"); + + for terminal_reference in terminal { + assert!( + evidence.contains(&terminal_reference["reference"]), + "terminal evidence must also be cited" + ); + } + match finding["class"].as_str().expect("finding class") { + "confirmedFailure" if finding["confidence"] == "high" => { + assert!( + !terminal.is_empty(), + "high confirmed failure needs terminal evidence" + ); + } + "insufficientEvidence" => { + assert!(!gaps.is_empty(), "insufficient evidence needs a gap"); + assert!( + !requests.is_empty(), + "insufficient evidence needs a request" + ); + } + _ => {} + } + } +} + +#[test] +fn management_point_reducer_matches_the_frozen_terminal_and_coverage_contracts() { + for scenario in SCENARIOS { + let directory = fixture_directory(scenario); + let expected = load_json(&directory.join("expected.json")); + let analysis = + serde_json::to_value(analyze_management_point_fixture(&load_bundle(scenario))) + .expect("MP analysis must serialize"); + + assert_eq!(analysis["schemaVersion"], 1, "{scenario}"); + assert_eq!(analysis["workflow"], "managementPoint", "{scenario}"); + assert_eq!( + analysis["stateChain"], + json!([ + "receiveRequest", + "authenticate", + "registerOrIdentify", + "resolveLocationOrPolicy", + "respond", + "recordOutcome" + ]), + "{scenario}" + ); + assert_eq!( + analysis["crossSideCorrelationPerformed"], false, + "{scenario}" + ); + assert_eq!( + actual_transaction_projection(&analysis), + expected_transaction_projection(&expected), + "{scenario}" + ); + assert_transaction_contract(scenario, &analysis, &expected); + assert_eq!( + source_local_projection(&analysis, true), + source_local_projection(&expected, false), + "{scenario}" + ); + assert_eq!( + actual_finding_signatures(&analysis), + expected_finding_signatures(&expected), + "{scenario}: finding semantics" + ); + assert_findings_are_cited_and_conservative(&analysis); + + let serialized = serde_json::to_string(&analysis).expect("analysis JSON"); + for prohibited in [ + "SYNTHETIC FIXTURE", + "synthetic-mp-", + "SYNTHETIC://", + "captureHost", + "executionContext", + "root cause", + "client impact", + ] { + assert!( + !serialized.contains(prohibited), + "{scenario}: public analysis leaked or claimed {prohibited}" + ); + } + } +} + +#[test] +fn canonical_intake_adapter_uses_assessed_mp_evidence_and_rejects_mismatches() { + let assessment = load_canonical_intake("canonical-intake-policy-scope"); + let analysis = analyze_management_point_from_server_intake(&assessment) + .expect("complete canonical MP source must enter the reducer"); + + assert!( + analysis.transactions.is_empty(), + "one policy-phase record is not a completed transaction" + ); + assert!(!analysis.cross_side_correlation_performed); + assert_eq!(analysis.source_local_observations.len(), 1); + assert!(analysis.source_local_observations[0] + .evidence + .iter() + .all(|reference| reference.artifact_id == "mp-policy-current")); + + let mut supplemental_iis = assessment.clone(); + let mut iis_artifact = supplemental_iis.artifacts[0].clone(); + iis_artifact.artifact_id = "mp-iis-skipped".to_owned(); + iis_artifact.source_id = "server-mp-iis".to_owned(); + iis_artifact.source_kind = "iisW3c".to_owned(); + iis_artifact.state = SccmCoverageState::Skipped; + iis_artifact.parser_eligible = false; + iis_artifact.fragment_complete = None; + iis_artifact.truncated = None; + supplemental_iis.artifacts.push(iis_artifact); + assert_unbound_intake_projection(&supplemental_iis, "supplemental artifact mutation"); + + let mut missing_role = assessment.clone(); + missing_role + .topology + .roles_observed + .retain(|role| *role != SccmRole::ManagementPoint); + assert_unbound_intake_projection(&missing_role, "missing topology role mutation"); + + let mut opaque_site_handle = assessment.clone(); + opaque_site_handle.topology.site_handle = format!("cmtraceopen.site.sha256.v1:{:064x}", 1); + assert_unbound_intake_projection(&opaque_site_handle, "site handle mutation"); + + let mut wrong_role = assessment.clone(); + wrong_role.artifacts[0].producer_role = SccmRole::SiteServer; + assert_unbound_intake_projection(&wrong_role, "artifact role mutation"); + + let mut wrong_profile = assessment.clone(); + wrong_profile.artifacts[0].source_version = Some("5.00.TEST.9999".to_owned()); + wrong_profile.artifacts[0].profile_eligible = true; + assert_unbound_intake_projection(&wrong_profile, "artifact profile mutation"); + + let mut wrong_source = assessment.clone(); + wrong_source.artifacts[0].source_id = "server-sitecomp".to_owned(); + assert_unbound_intake_projection(&wrong_source, "artifact source mutation"); + + let mut missing_coverage = assessment.clone(); + missing_coverage.coverage.clear(); + assert_unbound_intake_projection(&missing_coverage, "missing coverage mutation"); + + let mut capped = assessment.clone(); + capped.artifacts[0].state = SccmCoverageState::Capped; + capped.artifacts[0].truncated = Some(true); + capped.artifacts[0].fragment_complete = Some(false); + assert_unbound_intake_projection(&capped, "capped artifact mutation"); + + let mut fragment = assessment; + fragment.artifacts[0].fragment_complete = Some(false); + assert_unbound_intake_projection(&fragment, "fragment completeness mutation"); +} + +#[test] +fn canonical_intake_adapter_preflights_huge_same_count_coverage_membership() { + let mut assessment = load_canonical_intake("canonical-intake-policy-scope"); + let coverage_rows = assessment.coverage.len(); + assessment.coverage[0].artifact_ids = + std::iter::repeat_n("mp-policy-current".to_owned(), 65_536).collect(); + assert_eq!(assessment.coverage.len(), coverage_rows); + + reset_intake_integrity_work_probe(); + assert!(matches!( + analyze_management_point_from_server_intake(&assessment), + Err(SccmManagementPointIntakeError::SourceMismatch { artifact_id }) + if artifact_id == "management-point-intake-projection" + )); + assert_eq!( + intake_integrity_work_probe(), + (0, 0), + "nested membership inflation must fail before canonical cloning or JSON hashing" + ); +} + +#[test] +fn canonical_intake_adapter_preflights_huge_same_count_evidence_string() { + let mut assessment = load_canonical_intake("canonical-intake-policy-scope"); + let evidence_rows = assessment.evidence.len(); + assessment.evidence[0].message = "x".repeat(4 * 1024 * 1024); + assert_eq!(assessment.evidence.len(), evidence_rows); + + reset_intake_integrity_work_probe(); + assert!(matches!( + analyze_management_point_from_server_intake(&assessment), + Err(SccmManagementPointIntakeError::SourceMismatch { artifact_id }) + if artifact_id == "management-point-intake-projection" + )); + assert_eq!( + intake_integrity_work_probe(), + (0, 0), + "nested string inflation must fail before canonical cloning or JSON hashing" + ); +} + +#[test] +fn canonical_intake_adapter_admits_its_exact_synthetic_profile_version() { + let bundle = load_bundle("healthy-policy"); + assert_eq!( + bundle.sources[0].artifact.configmgr_version.as_deref(), + Some("5.00.TEST"), + "the frozen synthetic MP corpus must retain its exact profile version" + ); + + let analysis = analyze_management_point_fixture(&bundle); + assert_eq!( + analysis.transactions.len(), + 1, + "profile-shaped canonical evidence must not be silently filtered by a second version" + ); +} + +#[test] +fn canonical_intake_adapter_maps_captured_unspecified_fragment_to_complete() { + let assessment = load_canonical_intake("canonical-intake-policy-scope"); + let artifact = &assessment.artifacts[0]; + assert_eq!(artifact.state, SccmCoverageState::Captured); + assert_eq!(artifact.truncated, None); + assert_eq!( + artifact.fragment_complete, None, + "canonical intake represents a full captured artifact without fragment flags" + ); + + assert_eq!( + canonical_fragment_complete(artifact), + Some(true), + "the adapter must project canonical captured completeness for its internal reducer" + ); + let analysis = analyze_management_point_from_server_intake(&assessment) + .expect("the canonical captured artifact must enter MP reduction"); + assert_eq!( + analysis.source_local_observations.len(), + 1, + "the canonical policy record remains reducer-visible after completeness projection" + ); +} + +#[test] +fn canonical_intake_adapter_rejects_self_attested_line_authority() { + let mut assessment = load_canonical_intake("canonical-intake-policy-scope"); + let evidence = assessment + .evidence + .first_mut() + .expect("canonical fixture evidence"); + evidence.evidence_id = "mp-policy-current:999-999".to_owned(); + evidence.reference.entry_id = "mp-policy-current:999-999".to_owned(); + evidence.reference.line_start = Some(999); + evidence.reference.line_end = Some(999); + + assert_unbound_intake_projection( + &assessment, + "caller-submitted line ranges cannot become physical line authority", + ); +} + +#[test] +fn canonical_intake_adapter_rejects_forged_evidence_ownership() { + let assessment = load_canonical_intake("canonical-intake-policy-scope"); + + let mut foreign_artifact = assessment.clone(); + foreign_artifact.evidence[0].reference.artifact_id = "mp-policy-forged".to_owned(); + assert_unbound_intake_projection( + &foreign_artifact, + "evidence cannot be silently reassigned to an undeclared artifact", + ); + + let mut foreign_role = assessment; + foreign_role.evidence[0].role = SccmRole::SiteServer; + assert_unbound_intake_projection( + &foreign_role, + "evidence cannot be silently reassigned to another producer role", + ); +} + +#[test] +fn canonical_intake_adapter_rejects_forged_admission_metadata() { + let mut assessment = load_canonical_intake("canonical-intake-policy-scope"); + assessment.artifacts[0].producer_host_handle = + Some(format!("cmtraceopen.host.sha256.v1:{:064x}", 1)); + + assert_unbound_intake_projection( + &assessment, + "caller-submitted topology and artifact metadata cannot replace canonical intake authority", + ); +} + +#[test] +fn canonical_intake_adapter_accepts_reordered_authoritative_records() { + let assessment = load_server_intake_scenario("complete-multi-role"); + assert!( + assessment.artifacts.len() > 1, + "fixture must exercise artifact reordering" + ); + assert!( + assessment.coverage.len() > 1, + "fixture must exercise coverage reordering" + ); + assert!( + assessment.evidence.len() > 1, + "fixture must exercise evidence reordering" + ); + assert!( + assessment.coverage.iter().any(|record| { + record.producer_host_handle.is_some() && record.workflow_subject_handle.is_none() + }), + "fixture must retain rows with an absent optional workflow handle" + ); + assert!( + assessment.coverage.iter().any(|record| { + record.producer_host_handle.is_some() && record.workflow_subject_handle.is_some() + }), + "fixture must retain rows with both optional topology handles present" + ); + let expected = analyze_management_point_from_server_intake(&assessment) + .expect("canonical multi-role intake must enter the adapter"); + + let mut reordered = assessment; + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + assert_eq!( + analyze_management_point_from_server_intake(&reordered) + .expect("record ordering is not intake authority"), + expected + ); +} + +#[test] +fn canonical_intake_coverage_rows_distinguished_by_producer_host_reach_topology_validation() { + let directory = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots"); + let mut manifest = load_json(&directory.join("manifest.json")); + let fingerprint = + manifest["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"].clone(); + let lineage = manifest["artifacts"][0]["rotation"]["lineageId"].clone(); + manifest["artifacts"][1]["producerHostHandle"] = + Value::String("synthetic:host:site-01".to_owned()); + manifest["artifacts"][1]["configuredPathProvenance"]["pathFingerprint"] = fingerprint; + manifest["artifacts"][1]["rotation"]["lineageId"] = lineage; + + let assessment = assess_server_intake_manifest(&directory, &manifest); + let coverage = assessment + .coverage + .iter() + .filter(|record| record.source_id == "server-mp-policy") + .collect::>(); + assert_eq!( + coverage.len(), + 2, + "both physical producer rows are retained" + ); + assert_eq!( + coverage + .iter() + .map(|record| record.producer_host_handle.as_deref()) + .collect::>(), + vec![Some("synthetic:host:mp-01"), Some("synthetic:host:site-01")], + ); + + assert!( + matches!( + analyze_management_point_from_server_intake(&assessment), + Err(SccmManagementPointIntakeError::TopologyMismatch) + ), + "distinct producer-host coverage reaches MP topology validation rather than failing as an unbound intake projection" + ); + let mut reordered = assessment; + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + assert!( + matches!( + analyze_management_point_from_server_intake(&reordered), + Err(SccmManagementPointIntakeError::TopologyMismatch) + ), + "producer-host-bound coverage order is not authority" + ); +} + +#[test] +fn canonical_intake_adapter_accepts_coverage_rows_distinguished_by_workflow_subject() { + let directory = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/intake/complete-multi-role"); + let mut manifest = load_json(&directory.join("manifest.json")); + let payload_manifest = manifest.clone(); + let fingerprint = + manifest["artifacts"][2]["configuredPathProvenance"]["pathFingerprint"].clone(); + let artifact = &mut manifest["artifacts"][3]; + artifact["workflowSubject"] = json!({ + "role": "distributionPoint", + "instanceHandle": "synthetic:subject:dp-02", + }); + artifact["sourceId"] = Value::String("server-dp-distribution".to_owned()); + artifact["originalPath"] = Value::String("REDACTED_SITE_DP_CONTROL_ROOT_COPY".to_owned()); + artifact["originalBasename"] = Value::String("distmgr.log".to_owned()); + artifact["configuredPathProvenance"]["pathFingerprint"] = fingerprint; + artifact["relativePath"] = Value::String( + "evidence/sccm/server/site-server/server-dp-distribution/subject-distribution-point/instance-bbbbbbbb/current/distmgr.log" + .to_owned(), + ); + + let assessment = assess_server_intake_manifest_with_payload_manifest( + &directory, + &manifest, + &payload_manifest, + ); + let coverage = assessment + .coverage + .iter() + .filter(|record| record.source_id == "server-dp-distribution") + .collect::>(); + assert_eq!(coverage.len(), 2, "both workflow-subject rows are retained"); + assert_eq!( + coverage + .iter() + .map(|record| record.workflow_subject_handle.as_deref()) + .collect::>(), + vec![ + Some("synthetic:subject:dp-01"), + Some("synthetic:subject:dp-02"), + ], + ); + + let expected = analyze_management_point_from_server_intake(&assessment) + .expect("distinct workflow-subject coverage remains adapter-authoritative"); + let mut reordered = assessment; + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + assert_eq!( + analyze_management_point_from_server_intake(&reordered) + .expect("workflow-subject-bound coverage order is not authority"), + expected + ); +} + +#[test] +fn canonical_intake_adapter_rejects_post_intake_coverage_handle_mutations() { + let assessment = load_server_intake_scenario("complete-multi-role"); + let management_point_index = assessment + .coverage + .iter() + .position(|record| record.source_id == "server-mp-policy") + .expect("fixture has management-point coverage"); + let distribution_point_index = assessment + .coverage + .iter() + .position(|record| record.source_id == "server-dp-distribution") + .expect("fixture has distribution-point coverage"); + let software_update_point_index = assessment + .coverage + .iter() + .position(|record| record.source_id == "server-sup-sync") + .expect("fixture has software-update-point coverage"); + + let mut added = assessment.clone(); + added.coverage[management_point_index].workflow_subject_handle = + Some("synthetic:subject:mp-added".to_owned()); + assert_unbound_intake_projection(&added, "coverage handle addition mutation"); + + let mut removed_producer = assessment.clone(); + removed_producer.coverage[management_point_index].producer_host_handle = None; + assert_unbound_intake_projection( + &removed_producer, + "coverage producer-handle removal mutation", + ); + + let mut removed_subject = assessment.clone(); + removed_subject.coverage[distribution_point_index].workflow_subject_handle = None; + assert_unbound_intake_projection(&removed_subject, "coverage subject-handle removal mutation"); + + let mut swapped_producers = assessment.clone(); + let producer_handle = swapped_producers.coverage[management_point_index] + .producer_host_handle + .clone(); + swapped_producers.coverage[management_point_index].producer_host_handle = swapped_producers + .coverage[software_update_point_index] + .producer_host_handle + .clone(); + swapped_producers.coverage[software_update_point_index].producer_host_handle = producer_handle; + assert_unbound_intake_projection(&swapped_producers, "coverage producer-handle swap mutation"); + + let mut swapped_subjects = assessment; + let subject_handle = swapped_subjects.coverage[distribution_point_index] + .workflow_subject_handle + .clone(); + swapped_subjects.coverage[distribution_point_index].workflow_subject_handle = swapped_subjects + .coverage[software_update_point_index] + .workflow_subject_handle + .clone(); + swapped_subjects.coverage[software_update_point_index].workflow_subject_handle = subject_handle; + assert_unbound_intake_projection(&swapped_subjects, "coverage subject-handle swap mutation"); +} + +#[test] +fn canonical_intake_adapter_rejects_promoted_capped_profile_ineligible_metadata() { + let directory = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/server/intake/capped-sup"); + let mut manifest = load_json(&directory.join("manifest.json")); + manifest["artifacts"][0] + .as_object_mut() + .expect("capped fixture artifact") + .remove("sourceVersion"); + let manifest_json = serde_json::to_string(&manifest).expect("manifest serializes"); + let payloads = vec![SccmServerArtifactPayload { + manifest_artifact_id: "sup-sync-capped".to_owned(), + bytes: fs::read(directory.join( + "evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/instance-17eae15500d8968f/root-b11afca548220198/current/wsyncmgr.log", + )) + .expect("capped fixture payload"), + }]; + let assessment = assess_server_intake(&manifest_json, &payloads) + .expect("capped profile-ineligible intake must be canonical"); + assert_eq!(assessment.artifacts[0].state, SccmCoverageState::Capped); + assert!(!assessment.artifacts[0].profile_eligible); + + let sealed_evidence = assessment.evidence.clone(); + let mut promoted = assessment; + promoted.topology.roles_observed = vec![SccmRole::ManagementPoint]; + let artifact = &mut promoted.artifacts[0]; + artifact.producer_role = SccmRole::ManagementPoint; + artifact.producer_host_handle = Some("synthetic:host:mp-01".to_owned()); + artifact.workflow_subject_role = None; + artifact.workflow_subject_handle = None; + artifact.source_id = "server-mp-policy".to_owned(); + artifact.family = SccmArtifactFamily::ManagementPoint; + artifact.original_basename = Some("MP_GetPolicy.log".to_owned()); + artifact.state = SccmCoverageState::Captured; + artifact.source_version = Some("5.00.TEST".to_owned()); + artifact.profile_eligible = true; + artifact.truncated = None; + artifact.fragment_complete = None; + promoted.coverage[0].producer_role = SccmRole::ManagementPoint; + promoted.coverage[0].workflow_subject_role = None; + promoted.coverage[0].source_id = "server-mp-policy".to_owned(); + promoted.coverage[0].state = SccmCoverageState::Captured; + + assert_eq!( + promoted.evidence, sealed_evidence, + "unchanged evidence cannot authorize caller-promoted admission metadata" + ); + assert_unbound_intake_projection( + &promoted, + "unchanged evidence cannot authorize metadata promotion", + ); +} + +#[test] +fn canonical_intake_adapter_rejects_forged_message_and_timestamp() { + let assessment = load_canonical_intake("canonical-intake-policy-scope"); + + let mut forged_message = assessment.clone(); + forged_message.evidence[0] + .message + .push_str(" terminal outcome forged by caller"); + assert_unbound_intake_projection( + &forged_message, + "caller-submitted messages cannot replace intake-normalized evidence", + ); + + let mut forged_timestamp = assessment; + forged_timestamp.evidence[0].timestamp.utc_millis = Some(1_785_373_204_000); + assert_unbound_intake_projection( + &forged_timestamp, + "caller-submitted timestamps cannot replace intake-normalized provenance", + ); +} + +#[test] +fn canonical_intake_adapter_rejects_duplicate_and_colliding_evidence_identities() { + let assessment = load_canonical_intake("canonical-intake-policy-scope"); + + let mut duplicate = assessment.clone(); + duplicate.evidence.push(duplicate.evidence[0].clone()); + assert_unbound_intake_projection( + &duplicate, + "duplicate canonical evidence identities must fail closed", + ); + + let mut collision = assessment; + let mut colliding_evidence = collision.evidence[0].clone(); + colliding_evidence.message = "different record with the same evidence identity".to_owned(); + collision.evidence.push(colliding_evidence); + assert_unbound_intake_projection( + &collision, + "different evidence cannot collide under one canonical identity", + ); +} + +#[test] +fn canonical_intake_adapter_is_deterministic_under_valid_evidence_reordering() { + let assessment = load_server_intake_scenario("complete-multi-role"); + assert!( + assessment.evidence.len() > 1, + "fixture must exercise reordering" + ); + let expected = analyze_management_point_from_server_intake(&assessment) + .expect("canonical multi-role intake must be admitted"); + + let mut reordered = assessment; + reordered.evidence.reverse(); + let actual = analyze_management_point_from_server_intake(&reordered) + .expect("reordering intact canonical evidence must remain valid"); + + assert_eq!(actual, expected); +} + +#[test] +fn management_point_analysis_is_deterministic_under_bundle_reordering() { + for scenario in SCENARIOS { + let bundle = load_bundle(scenario); + let expected = serde_json::to_string(&analyze_management_point_fixture(&bundle)) + .expect("analysis JSON"); + + let mut reordered = bundle.clone(); + reordered.sources.reverse(); + reordered.evidence.reverse(); + let actual = serde_json::to_string(&analyze_management_point_fixture(&reordered)) + .expect("analysis JSON"); + assert_eq!(actual, expected, "{scenario}"); + } +} + +#[test] +fn management_point_counterpart_handoff_requires_an_exact_policy_key() { + for scenario in SCENARIOS { + let analysis = + serde_json::to_value(analyze_management_point_fixture(&load_bundle(scenario))).unwrap(); + for fact in analysis["counterpartReadyFacts"] + .as_array() + .expect("counterpart-ready facts") + { + assert_eq!(fact["key"]["confidence"], "exact", "{scenario}"); + assert!( + fact["key"]["policyId"].as_str().is_some(), + "{scenario}: policy counterpart fact needs a policy ID" + ); + assert_eq!( + fact["key"]["extractionProfileId"], "mp-server-5.00.test-v1", + "{scenario}" + ); + assert!( + fact["evidence"]["lineStart"].as_u64().is_some(), + "{scenario}: counterpart fact must cite evidence" + ); + } + } + + let unrelated = serde_json::to_value(analyze_management_point_fixture(&load_bundle( + "unrelated-client-like-key", + ))) + .unwrap(); + assert!( + unrelated["counterpartReadyFacts"] + .as_array() + .expect("counterpart facts") + .is_empty(), + "a matching-looking client key cannot become an MP counterpart fact" + ); + + let failed = serde_json::to_value(analyze_management_point_fixture(&load_bundle( + "policy-failure", + ))) + .expect("policy failure analysis"); + let failed_fact = failed["counterpartReadyFacts"] + .as_array() + .expect("counterpart facts") + .iter() + .find(|fact| fact["state"] == "failed") + .expect("failed policy counterpart fact"); + assert_eq!(failed_fact["classification"], "confirmedFailure"); + assert_eq!(failed_fact["confidence"], "high"); + assert_eq!( + failed_fact["terminalEvidence"], failed_fact["evidence"], + "a failed handoff must identify its terminal evidence" + ); +} + +#[test] +fn failed_counterpart_handoff_cites_the_decided_terminal_failure() { + let mut bundle = load_bundle("policy-failure"); + let later_outcome = bundle + .evidence + .iter_mut() + .find(|evidence| { + evidence.reference.artifact_id == "mp-policy-response-current" + && evidence.reference.line_start == Some(5) + }) + .expect("later policy evidence"); + later_outcome.message = "Record outcome succeeded RequestId={28555555-5555-5555-5555-555555555555} PolicyId={a8555555-5555-5555-5555-555555555555} ClientHandle={safe:client:mp-policy-primary-05} SiteCode={LAB} MPHandle={safe:mp:lab-mp-01}".to_owned(); + + let analysis = analysis_value(&bundle); + let failed_fact = analysis["counterpartReadyFacts"] + .as_array() + .expect("counterpart facts") + .iter() + .find(|fact| fact["state"] == "failed") + .expect("failed policy counterpart fact"); + + assert_eq!(failed_fact["phase"], "respond"); + assert_eq!(failed_fact["evidence"]["lineStart"], 2); + assert_eq!( + failed_fact["terminalEvidence"], failed_fact["evidence"], + "a later successful fact cannot masquerade as terminal failure evidence" + ); +} + +#[test] +fn management_point_catalog_declares_every_reducer_source() { + let mp_produced = declared_source_catalog() + .into_iter() + .filter(|source| { + source.role == SccmRole::ManagementPoint + && source.family == SccmArtifactFamily::ManagementPoint + }) + .map(|source| (source.basename, source.logical_name)) + .collect::>(); + let expected_mp_produced = [ + ("MP_CliReg.log", "mpCliReg"), + ("MP_GetAuth.log", "mpGetAuth"), + ("MP_GetPolicy.log", "mpGetPolicy"), + ("MP_Location.log", "mpLocation"), + ("MP_RegistrationManager.log", "mpRegistrationManager"), + ] + .into_iter() + .map(|(basename, logical_name)| (basename.to_owned(), logical_name.to_owned())) + .collect::>(); + assert_eq!( + mp_produced, expected_mp_produced, + "the MP-produced reducer sources are exactly the MP_* family" + ); + + let control = declared_source_catalog() + .into_iter() + .find(|source| source.basename == "mpcontrol.log") + .expect("mpcontrol.log stays declared in the shared catalog"); + assert_eq!( + control.role, + SccmRole::SiteServer, + "mpcontrol.log is produced by the site-server MP control workflow" + ); + assert_eq!(control.family, SccmArtifactFamily::ManagementPoint); + + let subject_row = declared_server_source_catalog() + .iter() + .find(|spec| { + spec.source_id == "server-mp-policy" && spec.producer_role == SccmRole::SiteServer + }) + .expect("subject-scoped mpcontrol server source row"); + assert_eq!( + subject_row.workflow_subject_role, + Some(SccmRole::ManagementPoint), + "mpcontrol is evidence about the Management Point, not by it" + ); + assert_eq!(subject_row.logical_names, ["mpcontrol"].as_slice()); +} + +fn analysis_value(bundle: &SccmManagementPointBundle) -> Value { + serde_json::to_value(analyze_management_point_fixture(bundle)).expect("analysis JSON") +} + +fn assert_no_high_success(value: &Value, context: &str) { + assert!( + value["transactions"] + .as_array() + .expect("transactions") + .iter() + .all(|transaction| { + transaction["state"] != "succeeded" || transaction["confidence"] != "high" + }), + "{context}: untrusted evidence produced high success" + ); +} + +#[test] +fn management_point_terminal_failure_requires_a_nonzero_result_and_an_exact_event_marker() { + let mut zero_result = load_bundle("auth-failure"); + let failed = zero_result + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Authenticate failed terminal")) + .expect("auth failure evidence"); + failed.message = failed.message.replace("Result=0x80010001", "Status=0"); + let analysis = analysis_value(&zero_result); + assert!( + analysis["transactions"] + .as_array() + .expect("transactions") + .iter() + .all(|transaction| transaction["classification"] != "confirmedFailure"), + "zero status is not a terminal MP failure" + ); + + let mut narrated_success = load_bundle("healthy-policy"); + let response = narrated_success + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Respond succeeded after retry")) + .expect("response success evidence"); + response.message = response.message.replace( + "Respond succeeded after retry", + "Do not treat Respond succeeded after retry text as an outcome", + ); + assert_no_high_success( + &analysis_value(&narrated_success), + "narrated event-marker text", + ); +} + +#[test] +fn management_point_exact_keys_reject_embedded_labels_suffixes_nil_ids_and_unsafe_handles() { + let base = load_bundle("healthy-policy"); + + let mut embedded_label = base.clone(); + for evidence in &mut embedded_label.evidence { + evidence.message = evidence.message.replace("RequestId=", "NotRequestId="); + } + assert!( + analysis_value(&embedded_label)["transactions"] + .as_array() + .expect("transactions") + .is_empty(), + "an embedded RequestId label is not an exact key" + ); + + let mut suffixed_uuid = base.clone(); + for evidence in &mut suffixed_uuid.evidence { + evidence.message = evidence.message.replace( + "RequestId={28111111-1111-1111-1111-111111111111}", + "RequestId={28111111-1111-1111-1111-111111111111}suffix", + ); + } + assert!( + analysis_value(&suffixed_uuid)["transactions"] + .as_array() + .expect("transactions") + .is_empty(), + "a UUID with trailing token data is not exact" + ); + + let mut nil_uuid = base.clone(); + for evidence in &mut nil_uuid.evidence { + evidence.message = evidence.message.replace( + "28111111-1111-1111-1111-111111111111", + "00000000-0000-0000-0000-000000000000", + ); + } + assert!( + analysis_value(&nil_uuid)["transactions"] + .as_array() + .expect("transactions") + .is_empty(), + "the nil UUID is not a usable request key" + ); + + let mut unsafe_handle = base; + for evidence in &mut unsafe_handle.evidence { + evidence.message = evidence + .message + .replace("safe:client:mp-healthy-01", "safe:client:..private"); + } + assert!( + analysis_value(&unsafe_handle)["transactions"] + .as_array() + .expect("transactions") + .is_empty(), + "an unsafe client handle is not correlation eligible" + ); +} + +#[test] +fn management_point_evidence_references_must_fit_the_captured_physical_source() { + let mut bundle = load_bundle("healthy-policy"); + let outcome = bundle + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Record outcome succeeded")) + .expect("outcome evidence"); + outcome.reference.line_end = Some(999); + outcome.reference.entry_id = format!("{}:4-999", outcome.reference.artifact_id); + outcome.evidence_id = outcome.reference.entry_id.clone(); + + let analysis = analysis_value(&bundle); + assert_no_high_success(&analysis, "out-of-bounds physical citation"); + assert!( + !serde_json::to_string(&analysis) + .expect("analysis JSON") + .contains("\"lineEnd\":999"), + "an out-of-bounds citation reached public output" + ); +} + +#[test] +fn management_point_noncaptured_sources_are_coverage_states_not_malformed_evidence() { + for coverage in [ + SccmCoverageState::AccessDenied, + SccmCoverageState::Capped, + SccmCoverageState::Skipped, + SccmCoverageState::Unsupported, + SccmCoverageState::ParseFailed, + ] { + let mut bundle = load_bundle("healthy-policy"); + let policy_artifact_id = bundle + .sources + .iter_mut() + .find(|source| source.source_group == "server-mp-policy") + .map(|source| { + source.artifact.coverage = coverage.clone(); + source.artifact.artifact_id.clone() + }) + .expect("policy source"); + let analysis = analysis_value(&bundle); + + assert_no_high_success(&analysis, "noncaptured policy source"); + assert!( + analysis["coverageGaps"] + .as_array() + .expect("coverage gaps") + .iter() + .any(|gap| { + gap["logicalArtifactId"] == "server-mp-policy" + && gap["state"] == serde_json::to_value(&coverage).expect("coverage state") + }), + "{coverage:?}: exact coverage state must be retained" + ); + assert!( + analysis["sourceLocalObservations"] + .as_array() + .expect("source-local observations") + .iter() + .all(|observation| { + observation["classification"] != "lowConfidenceSymptom" + || observation["evidence"] + .as_array() + .expect("observation evidence") + .iter() + .all(|reference| reference["artifactId"] != policy_artifact_id) + }), + "{coverage:?}: noncaptured bytes were misclassified as malformed evidence" + ); + } +} + +#[test] +fn management_point_profile_topology_source_and_time_mutations_fail_closed() { + let base = load_bundle("healthy-policy"); + + for version in [None, Some("5.00.UNKNOWN.0000")] { + let mut bundle = base.clone(); + for source in &mut bundle.sources { + source.artifact.configmgr_version = version.map(str::to_owned); + } + assert!( + analysis_value(&bundle)["transactions"] + .as_array() + .expect("transactions") + .is_empty(), + "{version:?}: unknown profile emitted an exact transaction" + ); + } + + let mut topology_mismatch = base.clone(); + topology_mismatch.topology.management_point_host_handle = + "safe:mp:other-management-point".to_owned(); + assert!( + analysis_value(&topology_mismatch)["transactions"] + .as_array() + .expect("transactions") + .is_empty(), + "incompatible topology emitted a transaction" + ); + + let mut wrong_source = base.clone(); + let policy_source = wrong_source + .sources + .iter_mut() + .find(|source| source.producer == "MP_GetPolicy") + .expect("policy source"); + policy_source.producer = "MP_GetAuth".to_owned(); + assert_no_high_success(&analysis_value(&wrong_source), "wrong source ownership"); + + let mut invalid_offset = base.clone(); + let response = invalid_offset + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Respond succeeded after retry")) + .expect("response success"); + response.timestamp.ordering_state = + cmtraceopen_parser::sccm::SccmTimeOrderingState::OffsetInvalid; + response.timestamp.utc_millis = None; + assert_no_high_success(&analysis_value(&invalid_offset), "invalid offset"); + + let mut inverted = base; + let receive_millis = inverted + .evidence + .iter() + .find(|evidence| evidence.message.contains("Receive request succeeded")) + .and_then(|evidence| evidence.timestamp.utc_millis) + .expect("receive UTC"); + let outcome = inverted + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Record outcome succeeded")) + .expect("outcome evidence"); + outcome.timestamp.utc_millis = Some(receive_millis - 1); + assert_no_high_success(&analysis_value(&inverted), "phase time inversion"); +} + +#[test] +fn management_point_output_never_exports_input_paths_hosts_or_raw_messages() { + let mut bundle = load_bundle("healthy-policy"); + for source in &mut bundle.sources { + source.artifact.original_path = + Some(r"C:\Users\Adam.Gell\private\MP_GetPolicy.log".to_owned()); + source.artifact.host = Some("LAB-MP01.private.example".to_owned()); + } + let evidence = bundle.evidence.first_mut().expect("fixture evidence"); + evidence + .message + .push_str(" AuthorizationHeader=Bearer private-secret; QueryHandle=SELECT private_object"); + + let serialized = + serde_json::to_string(&analyze_management_point_fixture(&bundle)).expect("analysis JSON"); + for prohibited in [ + "Adam.Gell", + "LAB-MP01", + "private.example", + "private-secret", + "private_object", + "AuthorizationHeader", + "QueryHandle", + ] { + assert!( + !serialized.contains(prohibited), + "public MP output leaked {prohibited}" + ); + } +} + +#[test] +fn management_point_duplicate_artifact_ids_are_ambiguous_not_order_authoritative() { + let mut first = load_bundle("healthy-policy"); + let mut duplicate = first + .sources + .iter() + .find(|source| source.producer == "MP_GetPolicy") + .expect("policy source") + .clone(); + duplicate.artifact.configmgr_version = Some("5.00.UNKNOWN.0000".to_owned()); + first.sources.push(duplicate); + + let mut second = first.clone(); + second.sources.reverse(); + let first_analysis = analysis_value(&first); + let second_analysis = analysis_value(&second); + assert_eq!( + first_analysis, second_analysis, + "duplicate artifact handling must not depend on vector order" + ); + assert_no_high_success(&first_analysis, "duplicate artifact identity"); +} + +#[test] +fn management_point_site_codes_are_canonicalized_for_counterpart_keys() { + let mut bundle = load_bundle("healthy-policy"); + bundle.topology.site_code = "lab".to_owned(); + for evidence in &mut bundle.evidence { + evidence.message = evidence.message.replace("SiteCode={LAB}", "SiteCode={lab}"); + } + + let analysis = analysis_value(&bundle); + assert_eq!(analysis["transactions"][0]["state"], "succeeded"); + assert_eq!(analysis["transactions"][0]["key"]["siteCode"], "LAB"); + assert_eq!( + analysis["counterpartReadyFacts"][0]["key"]["siteCode"], + "LAB" + ); +} + +#[test] +fn management_point_missing_captured_phase_is_not_reported_as_an_absent_artifact() { + let mut bundle = load_bundle("healthy-policy"); + let response = bundle + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Respond succeeded after retry")) + .expect("response success evidence"); + response.message = response.message.replace( + "Respond succeeded after retry", + "Respond candidate retained without an outcome", + ); + let deferred = bundle + .evidence + .iter_mut() + .find(|evidence| { + evidence + .message + .contains("Respond deferred retry scheduled") + }) + .expect("response deferred evidence"); + deferred.message = deferred.message.replace( + "Respond deferred retry scheduled", + "Respond candidate retained without a disposition", + ); + + let analysis = analysis_value(&bundle); + assert_no_high_success(&analysis, "missing captured response outcome"); + assert!( + analysis["coverageGaps"] + .as_array() + .expect("coverage gaps") + .iter() + .any(|gap| { + gap["logicalArtifactId"] == "server-mp-policy" && gap["state"] == "parseFailed" + }), + "captured-but-unusable phase evidence is not an absent artifact" + ); +} + +#[test] +fn management_point_conflicting_duplicate_key_labels_fail_closed() { + let mut accepted = Vec::new(); + for (label, duplicate) in [ + ( + "RequestId", + "RequestId={28999999-9999-9999-9999-999999999999}", + ), + ( + "PolicyId", + "PolicyId={a8999999-9999-9999-9999-999999999999}", + ), + ("ClientHandle", "ClientHandle={safe:client:mp-other-99}"), + ("SiteCode", "SiteCode={XYZ}"), + ("MPHandle", "MPHandle={safe:mp:other-mp-99}"), + ] { + let mut bundle = load_bundle("healthy-policy"); + for evidence in &mut bundle.evidence { + evidence.message.push(' '); + evidence.message.push_str(duplicate); + } + let analysis = analysis_value(&bundle); + if analysis["transactions"] + .as_array() + .expect("transactions") + .iter() + .any(|transaction| { + transaction["state"] == "succeeded" && transaction["confidence"] == "high" + }) + { + accepted.push(label); + } + } + + let mut failure = load_bundle("auth-failure"); + failure + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Authenticate failed terminal")) + .expect("terminal authentication failure") + .message + .push_str(" Result=0x00000000"); + if analysis_value(&failure)["transactions"] + .as_array() + .expect("transactions") + .iter() + .any(|transaction| { + transaction["state"] == "failed" + && transaction["classification"] == "confirmedFailure" + && transaction["confidence"] == "high" + }) + { + accepted.push("Result"); + } + + assert!( + accepted.is_empty(), + "conflicting duplicate exact-profile labels were accepted: {accepted:?}" + ); +} + +#[test] +fn management_point_later_deferred_phase_invalidates_earlier_success() { + let mut bundle = load_bundle("healthy-policy"); + let deferred_index = bundle + .evidence + .iter() + .position(|evidence| { + evidence + .message + .contains("Respond deferred retry scheduled") + }) + .expect("deferred response"); + let success_index = bundle + .evidence + .iter() + .position(|evidence| evidence.message.contains("Respond succeeded after retry")) + .expect("later response"); + bundle.evidence[deferred_index].message = bundle.evidence[deferred_index].message.replace( + "Respond deferred retry scheduled", + "Respond succeeded after retry", + ); + bundle.evidence[success_index].message = bundle.evidence[success_index].message.replace( + "Respond succeeded after retry", + "Respond deferred retry scheduled", + ); + + assert_no_high_success( + &analysis_value(&bundle), + "a later deferred phase observation", + ); +} + +#[test] +fn management_point_event_markers_require_an_exact_delimiter() { + let mut bundle = load_bundle("healthy-policy"); + let outcome = bundle + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Record outcome succeeded")) + .expect("record outcome"); + outcome.message = outcome + .message + .replace("Record outcome succeeded", "Record outcome succeededness"); + + assert_no_high_success( + &analysis_value(&bundle), + "an event marker with an alphanumeric suffix", + ); +} + +#[test] +fn management_point_conflicting_evidence_identity_reuse_fails_closed() { + let mut bundle = load_bundle("healthy-policy"); + let mut conflicting = bundle.evidence.clone(); + for evidence in &mut conflicting { + evidence.message = evidence + .message + .replace( + "28111111-1111-1111-1111-111111111111", + "28999999-9999-9999-9999-999999999999", + ) + .replace( + "a8111111-1111-1111-1111-111111111111", + "a8999999-9999-9999-9999-999999999999", + ) + .replace("safe:client:mp-healthy-01", "safe:client:mp-conflicting-99"); + } + bundle.evidence.extend(conflicting); + + let first = analysis_value(&bundle); + let high_successes = first["transactions"] + .as_array() + .expect("transactions") + .iter() + .filter(|transaction| { + transaction["state"] == "succeeded" && transaction["confidence"] == "high" + }) + .count(); + assert_eq!( + high_successes, 0, + "one physical evidence identity cannot authorize conflicting exact-key transactions" + ); + + bundle.evidence.reverse(); + assert_eq!( + analysis_value(&bundle), + first, + "ambiguous evidence identities must have deterministic public handling" + ); +} + +#[test] +fn management_point_overlapping_physical_ranges_fail_closed_deterministically() { + let mut bundle = load_bundle("healthy-policy"); + for evidence in &mut bundle.evidence { + if evidence.reference.artifact_id != "mp-healthy-policy-current" { + continue; + } + match evidence.reference.line_start { + Some(1) => { + evidence.reference.line_start = Some(2); + evidence.reference.line_end = Some(3); + evidence.reference.entry_id = "mp-review-overlap-a".to_owned(); + evidence.evidence_id = evidence.reference.entry_id.clone(); + } + Some(2) => { + evidence.reference.line_start = Some(3); + evidence.reference.line_end = Some(4); + evidence.reference.entry_id = "mp-review-overlap-b".to_owned(); + evidence.evidence_id = evidence.reference.entry_id.clone(); + } + _ => {} + } + } + + let first = analysis_value(&bundle); + assert_no_high_success(&first, "overlapping physical logical records"); + + bundle.evidence.reverse(); + assert_eq!( + analysis_value(&bundle), + first, + "overlap quarantine must not depend on bundle order" + ); +} + +#[test] +fn management_point_exact_labels_reject_hyphenated_prefixes() { + let mut accepted = Vec::new(); + for label in [ + "RequestId", + "PolicyId", + "ClientHandle", + "SiteCode", + "MPHandle", + ] { + let mut bundle = load_bundle("healthy-policy"); + for evidence in &mut bundle.evidence { + evidence.message = evidence + .message + .replace(&format!("{label}="), &format!("Not-{label}=")); + } + if analysis_value(&bundle)["transactions"] + .as_array() + .expect("transactions") + .iter() + .any(|transaction| { + transaction["state"] == "succeeded" && transaction["confidence"] == "high" + }) + { + accepted.push(label); + } + } + + let mut failure = load_bundle("auth-failure"); + let terminal = failure + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Authenticate failed terminal")) + .expect("terminal authentication failure"); + terminal.message = terminal.message.replace("Result=", "Not-Result="); + if analysis_value(&failure)["transactions"] + .as_array() + .expect("transactions") + .iter() + .any(|transaction| { + transaction["state"] == "failed" + && transaction["classification"] == "confirmedFailure" + && transaction["confidence"] == "high" + }) + { + accepted.push("Result"); + } + + assert!( + accepted.is_empty(), + "hyphen-prefixed exact-profile labels were accepted: {accepted:?}" + ); +} + +#[test] +fn successful_counterpart_handoff_requires_the_decisive_fact_to_prove_the_policy_key() { + let mut bundle = load_bundle("healthy-policy"); + let deferred = bundle + .evidence + .iter_mut() + .find(|evidence| { + evidence + .message + .contains("Respond deferred retry scheduled") + }) + .expect("deferred response"); + deferred.message = deferred.message.replace( + "Respond deferred retry scheduled", + "Respond succeeded before recovered outcome", + ); + + let earlier_outcome = bundle + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Respond succeeded after retry")) + .expect("earlier response"); + earlier_outcome.message = earlier_outcome.message.replace( + "Respond succeeded after retry", + "Record outcome failed terminal Result=0x80004005", + ); + + let decisive_success = bundle + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Record outcome succeeded")) + .expect("decisive successful outcome"); + decisive_success.message = decisive_success + .message + .replace(" PolicyId={a8111111-1111-1111-1111-111111111111}", ""); + + let analysis = analysis_value(&bundle); + let transaction = analysis["transactions"] + .as_array() + .expect("transactions") + .iter() + .find(|transaction| transaction["state"] == "succeeded") + .expect("recovered successful transaction"); + assert_eq!(transaction["phase"], "recordOutcome"); + assert_eq!(transaction["confidence"], "high"); + + assert!( + analysis["counterpartReadyFacts"] + .as_array() + .expect("counterpart facts") + .is_empty(), + "a decisive record without PolicyId cannot prove an exact policy counterpart key" + ); + + let baseline = analysis_value(&load_bundle("healthy-policy")); + let counterpart = baseline["counterpartReadyFacts"] + .as_array() + .expect("counterpart facts") + .iter() + .find(|fact| fact["state"] == "succeeded") + .expect("baseline successful counterpart"); + assert_eq!(counterpart["classification"], "success"); + assert_eq!( + counterpart["evidence"]["lineStart"], 4, + "the policy-bearing decisive success must remain correlation eligible" + ); + assert!( + counterpart["terminalEvidence"].is_null(), + "successful handoff cannot advertise terminal-failure evidence" + ); +} + +#[test] +fn management_point_result_codes_must_match_the_event_outcome() { + let mut nonzero_success = load_bundle("healthy-policy"); + nonzero_success + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Record outcome succeeded")) + .expect("successful outcome") + .message + .push_str(" Result=0x80004005"); + assert_no_high_success( + &analysis_value(&nonzero_success), + "nonzero terminal result on a success marker", + ); + + let mut zero_success = load_bundle("healthy-policy"); + zero_success + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Record outcome succeeded")) + .expect("successful outcome") + .message + .push_str(" Result=0x00000000"); + let zero_success_analysis = analysis_value(&zero_success); + assert!( + zero_success_analysis["transactions"] + .as_array() + .expect("transactions") + .iter() + .any(|transaction| { + transaction["state"] == "succeeded" && transaction["confidence"] == "high" + }), + "an explicit zero result must remain compatible with success" + ); + + let mut zero_failure = load_bundle("auth-failure"); + let failure = zero_failure + .evidence + .iter_mut() + .find(|evidence| evidence.message.contains("Authenticate failed terminal")) + .expect("terminal authentication failure"); + failure.message = failure + .message + .replace("Result=0x80010001", "Result=0x00000000"); + assert!( + analysis_value(&zero_failure)["transactions"] + .as_array() + .expect("transactions") + .iter() + .all(|transaction| transaction["classification"] != "confirmedFailure"), + "an explicit zero result cannot substantiate a terminal failure" + ); +} + +#[test] +fn management_point_transaction_citations_do_not_span_rejected_records() { + let mut bundle = load_bundle("healthy-policy"); + let rejected = bundle + .evidence + .iter_mut() + .find(|evidence| { + evidence.reference.artifact_id == "mp-healthy-policy-current" + && evidence.reference.line_start == Some(2) + }) + .expect("second policy record"); + rejected.message = rejected + .message + .replace("RequestId=", "MalformedRequestId="); + + let first = analysis_value(&bundle); + let transaction = first["transactions"] + .as_array() + .expect("transactions") + .iter() + .find(|transaction| { + transaction["state"] == "succeeded" && transaction["confidence"] == "high" + }) + .expect("remaining exact records still prove success"); + assert!( + transaction["evidence"] + .as_array() + .expect("transaction evidence") + .iter() + .filter(|reference| reference["artifactId"] == "mp-healthy-policy-current") + .all(|reference| { + let start = reference["lineStart"].as_u64().expect("line start"); + let end = reference["lineEnd"].as_u64().expect("line end"); + !(start <= 2 && 2 <= end) + }), + "transaction citation absorbed a rejected logical record" + ); + + bundle.evidence.reverse(); + assert_eq!( + analysis_value(&bundle), + first, + "disjoint exact citations must be stable under bundle reversal" + ); +} + +#[test] +fn site_server_mpcontrol_never_shapes_management_point_coverage_states() { + for control_coverage in [SccmCoverageState::Captured, SccmCoverageState::AccessDenied] { + let mut bundle = load_bundle("iis-supplemental"); + bundle + .evidence + .retain(|evidence| evidence.reference.artifact_id != "mp-iis-policy-current"); + bundle + .sources + .retain(|source| source.artifact.artifact_id != "mp-iis-policy-current"); + let control = bundle + .sources + .iter_mut() + .find(|source| source.artifact.artifact_id == "mp-iis-control-current") + .expect("subject-scoped mpcontrol source"); + assert_eq!( + control.artifact.role, + SccmRole::SiteServer, + "the fixture must model mpcontrol as site-server-produced" + ); + control.artifact.coverage = control_coverage.clone(); + + let analysis = analysis_value(&bundle); + assert_eq!( + analysis["coverageGaps"], + json!([{ + "logicalArtifactId": "server-mp-policy", + "role": "managementPoint", + "state": "absent", + }]), + "{control_coverage:?}: a site-server mpcontrol capture must not \ + masquerade as MP-produced policy coverage" + ); + } +} + +#[test] +fn mp_produced_mpcontrol_claims_fail_closed_as_rejected_evidence() { + let mut bundle = load_bundle("iis-supplemental"); + let control = bundle + .sources + .iter_mut() + .find(|source| source.artifact.artifact_id == "mp-iis-control-current") + .expect("subject-scoped mpcontrol source"); + control.artifact.role = SccmRole::ManagementPoint; + for evidence in &mut bundle.evidence { + if evidence.reference.artifact_id == "mp-iis-control-current" { + evidence.role = SccmRole::ManagementPoint; + } + } + + let analysis = analysis_value(&bundle); + let rejected = analysis["sourceLocalObservations"] + .as_array() + .expect("source-local observations") + .iter() + .find(|observation| { + observation["classification"] == "lowConfidenceSymptom" + && observation["correlationEligible"] == false + && observation["evidence"] + .as_array() + .expect("observation evidence") + .iter() + .any(|reference| reference["artifactId"] == "mp-iis-control-current") + }) + .expect( + "an mpcontrol source claiming MP production is a contract violation \ + and must surface as rejected evidence, not silent supplemental input", + ); + assert_eq!( + observation_request_groups(rejected), + vec!["server-mp-policy"], + "the rejected mpcontrol record belongs to the policy group, so its \ + remediation hint must request the MP-produced policy logs" + ); +} + +fn observation_request_groups(observation: &Value) -> Vec<&str> { + observation["nextArtifacts"] + .as_array() + .expect("observation requests") + .iter() + .map(|request| { + request["logicalArtifactId"] + .as_str() + .expect("request logical artifact id") + }) + .collect() +} + +#[test] +fn rejected_records_request_their_owning_source_group() { + let mut bundle = load_bundle("healthy-policy"); + bundle + .sources + .iter_mut() + .find(|source| source.artifact.artifact_id == "mp-healthy-auth-current") + .expect("auth source") + .fragment_complete = Some(false); + + let analysis = analysis_value(&bundle); + let observations = analysis["sourceLocalObservations"] + .as_array() + .expect("source-local observations"); + + let policy_rejected = observations + .iter() + .find(|observation| { + observation["evidence"] + .as_array() + .expect("observation evidence") + .iter() + .any(|reference| reference["artifactId"] == "mp-healthy-policy-current") + }) + .expect("rejected policy-group records must surface"); + assert_eq!( + observation_request_groups(policy_rejected), + vec!["server-mp-policy"], + "a rejected policy-group record must request its own group, \ + not MP_GetAuth.log" + ); + + let auth_rejected = observations + .iter() + .find(|observation| { + observation["evidence"] + .as_array() + .expect("observation evidence") + .iter() + .any(|reference| reference["artifactId"] == "mp-healthy-registration-current") + }) + .expect("rejected auth-group records must surface"); + assert_eq!( + observation_request_groups(auth_rejected), + vec!["server-mp-auth"], + "a rejected auth-group record keeps requesting the auth group" + ); +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs new file mode 100644 index 000000000..6a05f4ec8 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs @@ -0,0 +1,17 @@ +mod catalog; +mod distribution_point; +mod hierarchy; +mod intake; +mod management_point; +mod provider_and_admin_service; +mod site_core; +mod software_update_point; + +pub use catalog::*; +pub use distribution_point::*; +pub use hierarchy::*; +pub use intake::*; +pub use management_point::*; +pub use provider_and_admin_service::*; +pub use site_core::*; +pub use software_update_point::*; diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/provider_and_admin_service.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/provider_and_admin_service.rs new file mode 100644 index 000000000..95f447b83 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/provider_and_admin_service.rs @@ -0,0 +1,1207 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::models::log_entry::Severity; +use crate::sccm::{ + extract_keys, SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, SccmCorrelationKeyKind, + SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmExtractionProfile, SccmFinding, + SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, SccmKeyConfidence, SccmPhase, + SccmRole, SccmTerminalEvidence, SccmTimeOrderingState, +}; + +use super::{SccmServerArtifactAssessment, SccmServerIntakeAssessment}; + +const PROVIDER_SOURCE_ID: &str = "server-provider"; +const ADMIN_SOURCE_ID: &str = "server-admin-service"; +const IIS_SOURCE_ID: &str = "server-admin-service-iis"; +const SYNTHETIC_VERSION: &str = "5.00.TEST"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceLayer { + Provider, + AdminService, +} + +impl ProviderAdminServiceLayer { + fn role(self) -> SccmRole { + match self { + Self::Provider => SccmRole::Provider, + Self::AdminService => SccmRole::AdminService, + } + } + + fn source_id(self) -> &'static str { + match self { + Self::Provider => PROVIDER_SOURCE_ID, + Self::AdminService => ADMIN_SOURCE_ID, + } + } + + fn endpoint_token(self) -> &'static str { + match self { + Self::Provider => "provider-local", + Self::AdminService => "admin-service-lab", + } + } + + fn logical_artifact_id(self) -> &'static str { + match self { + Self::Provider => "smsprov", + Self::AdminService => "adminService", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServicePhase { + Receive, + AuthenticateOrAuthorize, + ExecuteProviderOperation, + Route, + ExecuteBackendOperation, + Respond, + RecordOutcome, +} + +impl ProviderAdminServicePhase { + fn rank(self, layer: ProviderAdminServiceLayer) -> Option { + match (layer, self) { + (_, Self::Receive) => Some(0), + (_, Self::AuthenticateOrAuthorize) => Some(1), + (ProviderAdminServiceLayer::Provider, Self::ExecuteProviderOperation) => Some(2), + (ProviderAdminServiceLayer::AdminService, Self::Route) => Some(2), + (ProviderAdminServiceLayer::AdminService, Self::ExecuteBackendOperation) => Some(3), + (ProviderAdminServiceLayer::Provider, Self::Respond) => Some(3), + (ProviderAdminServiceLayer::AdminService, Self::Respond) => Some(4), + (ProviderAdminServiceLayer::Provider, Self::RecordOutcome) => Some(4), + (ProviderAdminServiceLayer::AdminService, Self::RecordOutcome) => Some(5), + _ => None, + } + } + + fn is_last(self) -> bool { + self == Self::RecordOutcome + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceDisposition { + Succeeded, + Failed, + Pending, + RetryableFailure, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceState { + Succeeded, + Recovered, + Failed, + Contradictory, + BlockedOrDeferred, + Incomplete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceClassification { + Success, + Recovered, + ConfirmedFailure, + ContradictoryEvidence, + BlockedOrDeferred, + InsufficientEvidence, +} + +impl ProviderAdminServiceClassification { + pub fn shared_finding_class(self) -> Option { + match self { + Self::Success => None, + Self::Recovered => Some(SccmFindingClass::Recovered), + Self::ConfirmedFailure => Some(SccmFindingClass::ConfirmedFailure), + Self::ContradictoryEvidence => Some(SccmFindingClass::ContradictoryEvidence), + Self::BlockedOrDeferred => Some(SccmFindingClass::BlockedOrDeferred), + Self::InsufficientEvidence => Some(SccmFindingClass::InsufficientEvidence), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceTopologyCompatibility { + Exact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceTimestampOrdering { + Usable, + Unusable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceProfileSelection { + SelectedSynthetic, + UnknownVersion, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceSupportState { + SyntheticProfileOnly, + IntakeAuthorityInvalid, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAdminServiceProfile { + pub layer: ProviderAdminServiceLayer, + pub selection_state: ProviderAdminServiceProfileSelection, + pub extraction_profile: SccmExtractionProfile, + pub limitation: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAdminServiceCoverage { + pub artifact_id: String, + pub source_id: String, + pub producer_role: SccmRole, + pub producer_host_handle: Option, + pub workflow_subject_handle: Option, + pub source_version: Option, + pub state: SccmCoverageState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAdminServiceKey { + pub request_handle: String, + pub operation_handle: String, + pub endpoint_handle: String, + pub producer_host_handle: String, + pub confidence: SccmKeyConfidence, + pub extraction_profile: SccmExtractionProfile, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAdminServiceArtifactRequest { + pub layer: ProviderAdminServiceLayer, + pub producer_role: SccmRole, + pub producer_host_handle: String, + pub workflow_subject_handle: String, + pub source_version: Option, + pub request: SccmArtifactRequest, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAdminServiceFinding { + pub subject_id: String, + pub layer: ProviderAdminServiceLayer, + pub source_id: String, + pub producer_host_handle: String, + pub workflow_subject_handle: String, + pub source_version: Option, + pub last_successful_phase: Option, + pub finding: SccmFinding, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAdminServiceObservation { + pub observation_id: String, + pub phase: ProviderAdminServicePhase, + pub disposition: ProviderAdminServiceDisposition, + pub terminal: bool, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAdminServiceTransaction { + pub transaction_id: String, + pub layer: ProviderAdminServiceLayer, + pub producer_role: SccmRole, + pub source_version: String, + pub key: ProviderAdminServiceKey, + pub topology_compatibility: ProviderAdminServiceTopologyCompatibility, + pub timestamp_ordering: ProviderAdminServiceTimestampOrdering, + pub correlation_eligible: bool, + pub state: ProviderAdminServiceState, + pub classification: ProviderAdminServiceClassification, + pub confidence: SccmConfidence, + pub confidence_ceiling: SccmConfidence, + pub terminal_evidence: bool, + pub last_successful_phase: Option, + pub coverage_gap_artifact_ids: Vec, + pub next_artifact_requests: Vec, + pub public_summary: String, + pub observations: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceSourceLocalKind { + SupplementalOnly, + RotationFragment, + PrivacyRedacted, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAdminServiceSourceLocalObservation { + pub observation_id: String, + pub kind: ProviderAdminServiceSourceLocalKind, + pub artifact_ids: Vec, + pub correlation_eligible: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAdminServiceAnalysis { + pub workflow: &'static str, + pub support_state: ProviderAdminServiceSupportState, + pub profiles: Vec, + pub coverage: Vec, + pub transactions: Vec, + pub findings: Vec, + pub source_local_observations: Vec, + pub artifact_requests: Vec, + pub cross_side_causal_claims: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct FactKey { + layer: ProviderAdminServiceLayer, + request_id: String, + operation: String, + endpoint_handle: String, + host_handle: String, +} + +#[derive(Debug, Clone)] +struct Fact { + key: FactKey, + phase: ProviderAdminServicePhase, + disposition: ProviderAdminServiceDisposition, + terminal: bool, + evidence: SccmEvidenceRef, + utc_millis: Option, + extraction_profile: SccmExtractionProfile, +} + +struct ReducedTransaction { + transaction: ProviderAdminServiceTransaction, + finding: Option, +} + +enum ParsedFact { + Valid(Fact), + OrderingPoison(Fact), +} + +pub fn analyze_provider_admin_service( + intake: &SccmServerIntakeAssessment, +) -> ProviderAdminServiceAnalysis { + if !intake.adapter_authority_is_intake_bound() || !intake.topology_authority_is_intake_bound() { + return empty_analysis(); + } + + let scoped = intake + .artifacts + .iter() + .filter(|artifact| { + matches!( + artifact.source_id.as_str(), + PROVIDER_SOURCE_ID | ADMIN_SOURCE_ID | IIS_SOURCE_ID + ) + }) + .collect::>(); + let mut coverage = scoped + .iter() + .map(|artifact| ProviderAdminServiceCoverage { + artifact_id: artifact.artifact_id.clone(), + source_id: artifact.source_id.clone(), + producer_role: artifact.producer_role.clone(), + producer_host_handle: artifact.producer_host_handle.clone(), + workflow_subject_handle: artifact.workflow_subject_handle.clone(), + source_version: artifact.source_version.clone(), + state: artifact.state.clone(), + }) + .collect::>(); + coverage.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + + let mut facts = BTreeMap::>::new(); + let mut poisoned = BTreeSet::::new(); + for artifact in &scoped { + let Some(layer) = transaction_layer(artifact) else { + continue; + }; + if !artifact_admits_facts(intake, artifact, layer) { + continue; + } + for evidence in intake + .evidence + .iter() + .filter(|evidence| evidence.reference.artifact_id == artifact.artifact_id) + { + match parse_fact(artifact, evidence, layer) { + Some(ParsedFact::Valid(fact)) => { + facts.entry(fact.key.clone()).or_default().push(fact); + } + Some(ParsedFact::OrderingPoison(fact)) => { + poisoned.insert(fact.key.clone()); + facts.entry(fact.key.clone()).or_default().push(fact); + } + None => {} + } + } + } + + let mut reduced = facts + .into_iter() + .filter_map(|(key, group)| { + reduce_transaction(key.clone(), group, poisoned.contains(&key), &scoped) + }) + .collect::>(); + reduced.sort_by(|left, right| { + left.transaction + .transaction_id + .cmp(&right.transaction.transaction_id) + }); + let transactions = reduced + .iter() + .map(|reduced| reduced.transaction.clone()) + .collect::>(); + let mut findings = reduced + .into_iter() + .filter_map(|reduced| reduced.finding) + .collect::>(); + findings.extend(coverage_findings(&scoped)); + findings.sort_by(|left, right| left.finding.finding_id.cmp(&right.finding.finding_id)); + + let mut source_local_observations = source_local_observations(&scoped, &intake.evidence); + source_local_observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + + let mut artifact_requests = global_artifact_requests(&scoped); + for request in transactions + .iter() + .flat_map(|transaction| transaction.next_artifact_requests.clone()) + { + if !artifact_requests.contains(&request) { + artifact_requests.push(request); + } + } + artifact_requests.sort_by(|left, right| { + ( + left.layer, + left.producer_host_handle.as_str(), + left.workflow_subject_handle.as_str(), + left.request.logical_id.as_str(), + ) + .cmp(&( + right.layer, + right.producer_host_handle.as_str(), + right.workflow_subject_handle.as_str(), + right.request.logical_id.as_str(), + )) + }); + + ProviderAdminServiceAnalysis { + workflow: "providerAndAdminService", + support_state: ProviderAdminServiceSupportState::SyntheticProfileOnly, + profiles: selected_profiles(&scoped), + coverage, + transactions, + findings, + source_local_observations, + artifact_requests, + cross_side_causal_claims: Vec::new(), + } +} + +fn empty_analysis() -> ProviderAdminServiceAnalysis { + ProviderAdminServiceAnalysis { + workflow: "providerAndAdminService", + support_state: ProviderAdminServiceSupportState::IntakeAuthorityInvalid, + profiles: Vec::new(), + coverage: Vec::new(), + transactions: Vec::new(), + findings: Vec::new(), + source_local_observations: Vec::new(), + artifact_requests: Vec::new(), + cross_side_causal_claims: Vec::new(), + } +} + +fn selected_profiles( + artifacts: &[&SccmServerArtifactAssessment], +) -> Vec { + [ + ProviderAdminServiceLayer::Provider, + ProviderAdminServiceLayer::AdminService, + ] + .into_iter() + .filter(|layer| { + artifacts + .iter() + .any(|artifact| artifact.source_id == layer.source_id()) + }) + .filter_map(|layer| { + let selected = artifacts.iter().any(|artifact| { + artifact.source_id == layer.source_id() + && artifact.source_version.as_deref() == Some(SYNTHETIC_VERSION) + }); + let extraction_profile = registered_profile(layer)?; + Some(ProviderAdminServiceProfile { + layer, + selection_state: if selected { + ProviderAdminServiceProfileSelection::SelectedSynthetic + } else { + ProviderAdminServiceProfileSelection::UnknownVersion + }, + extraction_profile, + limitation: + "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + }) + }) + .collect() +} + +fn registered_profile(layer: ProviderAdminServiceLayer) -> Option { + Some(SccmExtractionProfile::for_artifact_family( + Some(SYNTHETIC_VERSION), + &match layer { + ProviderAdminServiceLayer::Provider => SccmArtifactFamily::Provider, + ProviderAdminServiceLayer::AdminService => SccmArtifactFamily::AdminService, + }, + )) +} + +fn transaction_layer(artifact: &SccmServerArtifactAssessment) -> Option { + match artifact.source_id.as_str() { + PROVIDER_SOURCE_ID => Some(ProviderAdminServiceLayer::Provider), + ADMIN_SOURCE_ID => Some(ProviderAdminServiceLayer::AdminService), + _ => None, + } +} + +fn artifact_admits_facts( + intake: &SccmServerIntakeAssessment, + artifact: &SccmServerArtifactAssessment, + layer: ProviderAdminServiceLayer, +) -> bool { + artifact.state == SccmCoverageState::Captured + && artifact.parser_eligible + && artifact.profile_eligible + && artifact.fragment_complete != Some(false) + && artifact.source_version.as_deref() == Some(SYNTHETIC_VERSION) + && artifact.producer_role == layer.role() + && artifact.workflow_subject_role == Some(layer.role()) + && artifact.workflow_subject_handle.is_some() + && intake.topology.roles_observed.contains(&layer.role()) + && matches!( + ( + layer, + &artifact.family, + artifact.original_basename.as_deref() + ), + ( + ProviderAdminServiceLayer::Provider, + SccmArtifactFamily::Provider, + Some("Smsprov.log") + ) | ( + ProviderAdminServiceLayer::AdminService, + SccmArtifactFamily::AdminService, + Some("AdminService.log") + ) + ) +} + +fn parse_fact( + artifact: &SccmServerArtifactAssessment, + evidence: &SccmEvidence, + layer: ProviderAdminServiceLayer, +) -> Option { + if evidence.role != layer.role() { + return None; + } + let fields = parse_fields(&evidence.message)?; + let extraction_profile = SccmExtractionProfile::for_artifact_family( + artifact.source_version.as_deref(), + &artifact.family, + ); + if fields.get("Layer")?.as_str() != layer_name(layer) + || fields.get("ProfileId")?.as_str() != extraction_profile.profile_id + || fields.get("EndpointId")?.as_str() != layer.endpoint_token() + { + return None; + } + let request_id = fields.get("RequestId")?.to_ascii_lowercase(); + let operation = fields.get("OperationHandle")?.clone(); + if !uuid_is_exact(&request_id) || !safe_operation(&operation) { + return None; + } + let extraction = extract_keys(evidence, &extraction_profile); + let shared_request_keys = extraction + .keys + .iter() + .filter(|key| key.kind == SccmCorrelationKeyKind::RequestId) + .collect::>(); + let [shared_request_key] = shared_request_keys.as_slice() else { + return None; + }; + if shared_request_key.normalized != request_id + || shared_request_key.confidence != SccmKeyConfidence::Low + || shared_request_key.extraction_profile_id.as_deref() + != Some(extraction_profile.profile_id.as_str()) + { + return None; + } + let key = FactKey { + layer, + request_id, + operation, + endpoint_handle: artifact.workflow_subject_handle.clone()?, + host_handle: artifact.producer_host_handle.clone()?, + }; + let phase = parse_phase(fields.get("Phase")?, layer)?; + let disposition = parse_disposition(fields.get("Disposition")?)?; + let terminal = match fields.get("Terminal")?.as_str() { + "true" => true, + "false" => false, + _ => return None, + }; + if terminal + && (!phase.is_last() + || !matches!( + disposition, + ProviderAdminServiceDisposition::Succeeded + | ProviderAdminServiceDisposition::Failed + )) + { + return None; + } + let utc_millis = match ( + &evidence.timestamp.ordering_state, + evidence.timestamp.utc_millis, + ) { + (SccmTimeOrderingState::NormalizedUtc, Some(value)) => Some(value), + _ => None, + }; + let fact = Fact { + key, + phase, + disposition, + terminal, + evidence: evidence.reference.clone(), + utc_millis, + extraction_profile, + }; + Some(if fact.utc_millis.is_some() { + ParsedFact::Valid(fact) + } else { + ParsedFact::OrderingPoison(fact) + }) +} + +fn parse_fields(message: &str) -> Option> { + let message = message.strip_prefix("[sccm-public-message-v1] ")?; + let body = message.strip_prefix("SYNTHETIC FIXTURE; ")?; + let mut fields = BTreeMap::new(); + for segment in body.split(';').map(str::trim) { + if segment == "[redacted:sccm-public-message-v1]" { + continue; + } + let (name, value) = segment.split_once('=')?; + if !matches!( + name, + "Phase" + | "Disposition" + | "Terminal" + | "RequestId" + | "OperationHandle" + | "EndpointId" + | "Layer" + | "ProfileId" + | "CallerHandle" + | "Authorization" + | "QueryHandle" + ) || fields.insert(name, value.to_owned()).is_some() + { + return None; + } + } + Some(fields) +} + +fn parse_phase(value: &str, layer: ProviderAdminServiceLayer) -> Option { + let phase = match value { + "receive" => ProviderAdminServicePhase::Receive, + "authenticateOrAuthorize" => ProviderAdminServicePhase::AuthenticateOrAuthorize, + "executeProviderOperation" => ProviderAdminServicePhase::ExecuteProviderOperation, + "route" => ProviderAdminServicePhase::Route, + "executeBackendOperation" => ProviderAdminServicePhase::ExecuteBackendOperation, + "respond" => ProviderAdminServicePhase::Respond, + "recordOutcome" => ProviderAdminServicePhase::RecordOutcome, + _ => return None, + }; + phase.rank(layer).map(|_| phase) +} + +fn parse_disposition(value: &str) -> Option { + Some(match value { + "succeeded" => ProviderAdminServiceDisposition::Succeeded, + "failed" => ProviderAdminServiceDisposition::Failed, + "pending" => ProviderAdminServiceDisposition::Pending, + "retryableFailure" => ProviderAdminServiceDisposition::RetryableFailure, + _ => return None, + }) +} + +fn reduce_transaction( + key: FactKey, + mut facts: Vec, + ordering_poisoned: bool, + artifacts: &[&SccmServerArtifactAssessment], +) -> Option { + if !ordering_poisoned { + facts.sort_by_key(|fact| fact.utc_millis); + } + if facts + .first() + .is_none_or(|fact| fact.phase != ProviderAdminServicePhase::Receive) + { + return None; + } + let strict_time = !ordering_poisoned + && facts.windows(2).all(|pair| { + matches!((pair[0].utc_millis, pair[1].utc_millis), (Some(left), Some(right)) if left < right) + }); + let observations = facts + .iter() + .enumerate() + .map(|(index, fact)| ProviderAdminServiceObservation { + observation_id: format!("{}-{:02}", fact.evidence.entry_id, index + 1), + phase: fact.phase, + disposition: fact.disposition, + terminal: fact.terminal, + evidence: vec![fact.evidence.clone()], + }) + .collect::>(); + let terminal_success = facts.iter().any(|fact| { + fact.terminal && fact.disposition == ProviderAdminServiceDisposition::Succeeded + }); + let terminal_failure = facts + .iter() + .any(|fact| fact.terminal && fact.disposition == ProviderAdminServiceDisposition::Failed); + let contradictory = terminal_success && terminal_failure; + let deferred = facts + .iter() + .any(|fact| fact.disposition == ProviderAdminServiceDisposition::Pending); + let phase_valid = phase_chain_is_valid(key.layer, &facts); + let full_success = full_success_chain(key.layer, &facts); + let gap_artifacts = artifacts + .iter() + .filter(|artifact| { + artifact.source_id == key.layer.source_id() + && artifact.producer_host_handle.as_deref() == Some(&key.host_handle) + && artifact.workflow_subject_handle.as_deref() == Some(&key.endpoint_handle) + && (artifact.state != SccmCoverageState::Captured + || artifact.fragment_complete == Some(false)) + }) + .collect::>(); + let gaps = gap_artifacts + .iter() + .map(|artifact| artifact.artifact_id.clone()) + .collect::>() + .into_iter() + .collect::>(); + let ordering_usable = strict_time && !ordering_poisoned; + let last_successful_phase = if ordering_usable { + facts + .iter() + .rev() + .find(|fact| fact.disposition == ProviderAdminServiceDisposition::Succeeded) + .map(|fact| fact.phase) + } else { + None + }; + let recovered = facts.windows(2).any(|pair| { + pair[0].phase == pair[1].phase + && pair[0].disposition == ProviderAdminServiceDisposition::RetryableFailure + && pair[1].disposition == ProviderAdminServiceDisposition::Succeeded + }); + let conclusive = ordering_usable && phase_valid && gaps.is_empty() && !contradictory; + let (state, classification, confidence, summary) = if contradictory { + ( + ProviderAdminServiceState::Contradictory, + ProviderAdminServiceClassification::ContradictoryEvidence, + SccmConfidence::Low, + format!( + "{} records mutually exclusive terminal outcomes.", + display_layer(key.layer) + ), + ) + } else if !gaps.is_empty() || !phase_valid || !ordering_usable { + ( + ProviderAdminServiceState::Incomplete, + ProviderAdminServiceClassification::InsufficientEvidence, + SccmConfidence::Low, + format!( + "{} evidence is incomplete, contradictory, or not comparably ordered.", + display_layer(key.layer) + ), + ) + } else if deferred && !terminal_success && !terminal_failure { + ( + ProviderAdminServiceState::BlockedOrDeferred, + ProviderAdminServiceClassification::BlockedOrDeferred, + SccmConfidence::Moderate, + format!( + "{} evidence records a blocked or deferred request without a terminal outcome.", + display_layer(key.layer) + ), + ) + } else if conclusive && terminal_failure && !terminal_success { + ( + ProviderAdminServiceState::Failed, + ProviderAdminServiceClassification::ConfirmedFailure, + SccmConfidence::High, + format!( + "{} recorded an explicit terminal operation failure.", + display_layer(key.layer) + ), + ) + } else if conclusive && terminal_success && full_success && recovered { + ( + ProviderAdminServiceState::Recovered, + ProviderAdminServiceClassification::Recovered, + SccmConfidence::High, + format!( + "{} operation recovered after an explicit retryable failure.", + display_layer(key.layer) + ), + ) + } else if conclusive && terminal_success && full_success { + ( + ProviderAdminServiceState::Succeeded, + ProviderAdminServiceClassification::Success, + SccmConfidence::High, + format!( + "{} operation completed with explicit terminal evidence.", + display_layer(key.layer) + ), + ) + } else { + ( + ProviderAdminServiceState::Incomplete, + ProviderAdminServiceClassification::InsufficientEvidence, + SccmConfidence::Low, + format!( + "{} evidence stops before a valid explicit terminal outcome.", + display_layer(key.layer) + ), + ) + }; + let mut requests = gap_artifacts + .iter() + .filter_map(|artifact| scoped_artifact_request(artifact, key.layer)) + .collect::>(); + if requests.is_empty() + && matches!( + state, + ProviderAdminServiceState::Incomplete | ProviderAdminServiceState::BlockedOrDeferred + ) + { + requests.push(ProviderAdminServiceArtifactRequest { + layer: key.layer, + producer_role: key.layer.role(), + producer_host_handle: key.host_handle.clone(), + workflow_subject_handle: key.endpoint_handle.clone(), + source_version: Some(SYNTHETIC_VERSION.to_owned()), + request: artifact_request(key.layer), + }); + } + deduplicate_requests(&mut requests); + let request_handle = public_handle("request", &key.request_id); + let operation_handle = public_handle("operation", &key.operation); + let transaction_id = format!( + "{}:{request_handle}:{operation_handle}:{}:{}", + layer_name(key.layer), + key.host_handle, + key.endpoint_handle + ); + let extraction_profile = facts.first()?.extraction_profile.clone(); + let transaction = ProviderAdminServiceTransaction { + transaction_id, + layer: key.layer, + producer_role: key.layer.role(), + source_version: SYNTHETIC_VERSION.to_owned(), + key: ProviderAdminServiceKey { + request_handle, + operation_handle, + endpoint_handle: key.endpoint_handle, + producer_host_handle: key.host_handle, + confidence: SccmKeyConfidence::Low, + extraction_profile, + }, + topology_compatibility: ProviderAdminServiceTopologyCompatibility::Exact, + timestamp_ordering: if ordering_usable { + ProviderAdminServiceTimestampOrdering::Usable + } else { + ProviderAdminServiceTimestampOrdering::Unusable + }, + correlation_eligible: false, + state, + classification, + confidence, + confidence_ceiling: confidence, + terminal_evidence: terminal_success || terminal_failure, + last_successful_phase, + coverage_gap_artifact_ids: gaps, + next_artifact_requests: requests, + public_summary: summary, + observations, + }; + let finding = transaction_finding(&transaction, &facts, &gap_artifacts); + Some(ReducedTransaction { + transaction, + finding, + }) +} + +fn transaction_finding( + transaction: &ProviderAdminServiceTransaction, + facts: &[Fact], + gap_artifacts: &[&&SccmServerArtifactAssessment], +) -> Option { + let mut class = transaction.classification.shared_finding_class()?; + if class == SccmFindingClass::InsufficientEvidence && gap_artifacts.is_empty() { + class = SccmFindingClass::Symptom; + } + let severity = match transaction.classification { + ProviderAdminServiceClassification::Success => return None, + ProviderAdminServiceClassification::Recovered => Severity::Success, + ProviderAdminServiceClassification::ConfirmedFailure => Severity::Error, + ProviderAdminServiceClassification::ContradictoryEvidence + | ProviderAdminServiceClassification::BlockedOrDeferred + | ProviderAdminServiceClassification::InsufficientEvidence => Severity::Warning, + }; + let evidence = facts + .iter() + .map(|fact| fact.evidence.clone()) + .collect::>(); + let terminal_evidence = facts + .iter() + .filter(|fact| fact.terminal && fact.disposition == ProviderAdminServiceDisposition::Failed) + .map(|fact| SccmTerminalEvidence::observed_failure(fact.evidence.clone())) + .collect::>(); + let coverage_gaps = gap_artifacts + .iter() + .map(|artifact| SccmFindingCoverageGap { + artifact_id: artifact.artifact_id.clone(), + role: artifact.producer_role.clone(), + coverage: artifact.state.clone(), + }) + .collect::>(); + let finding_id = format!( + "provider-admin-finding:{}", + public_handle("finding", &transaction.transaction_id) + ); + let finding = SccmFindingBuilder::new(finding_id) + .class(class) + .phase(SccmPhase::Unknown("providerAndAdminService".to_owned())) + .role(transaction.producer_role.clone()) + .severity(severity) + .confidence(transaction.confidence) + .title(format!( + "{} {}", + display_layer(transaction.layer), + classification_name(transaction.classification) + )) + .summary(transaction.public_summary.clone()) + .evidence(evidence) + .terminal_evidence(terminal_evidence) + .coverage_gaps(coverage_gaps) + .next_artifacts( + transaction + .next_artifact_requests + .iter() + .map(|request| request.request.clone()) + .collect(), + ) + .build() + .expect("provider/admin reducer must emit a valid shared finding"); + Some(ProviderAdminServiceFinding { + subject_id: transaction.transaction_id.clone(), + layer: transaction.layer, + source_id: transaction.layer.source_id().to_owned(), + producer_host_handle: transaction.key.producer_host_handle.clone(), + workflow_subject_handle: transaction.key.endpoint_handle.clone(), + source_version: Some(transaction.source_version.clone()), + last_successful_phase: transaction.last_successful_phase, + finding, + }) +} + +fn coverage_findings( + artifacts: &[&SccmServerArtifactAssessment], +) -> Vec { + artifacts + .iter() + .filter_map(|artifact| { + let layer = transaction_layer(artifact)?; + if artifact.state == SccmCoverageState::Captured + && artifact.fragment_complete != Some(false) + { + return None; + } + let request = scoped_artifact_request(artifact, layer)?; + let finding = SccmFindingBuilder::new(format!( + "provider-admin-coverage:{}", + artifact.artifact_id + )) + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Unknown("providerAndAdminService".to_owned())) + .role(artifact.producer_role.clone()) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .title(format!("{} evidence unavailable", display_layer(layer))) + .summary(format!( + "{} cannot be evaluated because its scoped source is not a complete capture.", + display_layer(layer) + )) + .coverage_gap(SccmFindingCoverageGap { + artifact_id: artifact.artifact_id.clone(), + role: artifact.producer_role.clone(), + coverage: artifact.state.clone(), + }) + .next_artifact(request.request.clone()) + .build() + .expect("provider/admin coverage reducer must emit a valid shared finding"); + Some(ProviderAdminServiceFinding { + subject_id: artifact.artifact_id.clone(), + layer, + source_id: artifact.source_id.clone(), + producer_host_handle: artifact.producer_host_handle.clone()?, + workflow_subject_handle: artifact.workflow_subject_handle.clone()?, + source_version: artifact.source_version.clone(), + last_successful_phase: None, + finding, + }) + }) + .collect() +} + +fn phase_chain_is_valid(layer: ProviderAdminServiceLayer, facts: &[Fact]) -> bool { + let mut previous_rank = None; + let mut retry_phase = None; + for fact in facts { + let Some(rank) = fact.phase.rank(layer) else { + return false; + }; + if let Some(previous) = previous_rank { + if (rank < previous || rank > previous + 1) && !fact.terminal { + return false; + } + if rank == previous { + let retry_recovery = retry_phase == Some(rank) + && fact.disposition == ProviderAdminServiceDisposition::Succeeded; + let terminal_contradiction = fact.phase.is_last() && fact.terminal; + if !retry_recovery && !terminal_contradiction { + return false; + } + } + } + retry_phase = + (fact.disposition == ProviderAdminServiceDisposition::RetryableFailure).then_some(rank); + previous_rank = Some(rank); + } + true +} + +fn full_success_chain(layer: ProviderAdminServiceLayer, facts: &[Fact]) -> bool { + let last_rank = ProviderAdminServicePhase::RecordOutcome + .rank(layer) + .unwrap_or_default(); + (0..=last_rank).all(|rank| { + facts.iter().any(|fact| { + fact.phase.rank(layer) == Some(rank) + && fact.disposition == ProviderAdminServiceDisposition::Succeeded + }) + }) +} + +fn source_local_observations( + artifacts: &[&SccmServerArtifactAssessment], + evidence: &[SccmEvidence], +) -> Vec { + let mut result = Vec::new(); + for artifact in artifacts { + if artifact.source_id == IIS_SOURCE_ID { + result.push(ProviderAdminServiceSourceLocalObservation { + observation_id: format!("{}-supplemental", artifact.artifact_id), + kind: ProviderAdminServiceSourceLocalKind::SupplementalOnly, + artifact_ids: vec![artifact.artifact_id.clone()], + correlation_eligible: false, + }); + } + if artifact.fragment_complete == Some(false) { + result.push(ProviderAdminServiceSourceLocalObservation { + observation_id: format!("{}-rotation", artifact.artifact_id), + kind: ProviderAdminServiceSourceLocalKind::RotationFragment, + artifact_ids: vec![artifact.artifact_id.clone()], + correlation_eligible: false, + }); + } + if evidence.iter().any(|item| { + item.reference.artifact_id == artifact.artifact_id + && item.message.contains("[redacted:") + }) { + result.push(ProviderAdminServiceSourceLocalObservation { + observation_id: format!("{}-privacy", artifact.artifact_id), + kind: ProviderAdminServiceSourceLocalKind::PrivacyRedacted, + artifact_ids: vec![artifact.artifact_id.clone()], + correlation_eligible: false, + }); + } + } + result +} + +fn global_artifact_requests( + artifacts: &[&SccmServerArtifactAssessment], +) -> Vec { + let mut requests = artifacts + .iter() + .filter_map(|artifact| { + let layer = transaction_layer(artifact)?; + (artifact.state != SccmCoverageState::Captured + || artifact.fragment_complete == Some(false)) + .then(|| scoped_artifact_request(artifact, layer))? + }) + .collect::>(); + deduplicate_requests(&mut requests); + requests +} + +fn scoped_artifact_request( + artifact: &SccmServerArtifactAssessment, + layer: ProviderAdminServiceLayer, +) -> Option { + Some(ProviderAdminServiceArtifactRequest { + layer, + producer_role: artifact.producer_role.clone(), + producer_host_handle: artifact.producer_host_handle.clone()?, + workflow_subject_handle: artifact.workflow_subject_handle.clone()?, + source_version: artifact.source_version.clone(), + request: artifact_request(layer), + }) +} + +fn deduplicate_requests(requests: &mut Vec) { + requests.sort_by(|left, right| { + ( + left.layer, + left.producer_host_handle.as_str(), + left.workflow_subject_handle.as_str(), + left.source_version.as_deref(), + left.request.logical_id.as_str(), + ) + .cmp(&( + right.layer, + right.producer_host_handle.as_str(), + right.workflow_subject_handle.as_str(), + right.source_version.as_deref(), + right.request.logical_id.as_str(), + )) + }); + requests.dedup_by(|left, right| left == right); +} + +fn artifact_request(layer: ProviderAdminServiceLayer) -> SccmArtifactRequest { + SccmArtifactRequest { + logical_id: layer.logical_artifact_id().to_owned(), + role: layer.role(), + reason: format!( + "Collect the complete {} file.", + if layer == ProviderAdminServiceLayer::Provider { + "Smsprov.log" + } else { + "AdminService.log" + } + ), + } +} + +fn public_handle(domain: &str, value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"cmtraceopen.provider-admin-service.public-handle.v1\0"); + hasher.update(domain.as_bytes()); + hasher.update(b"\0"); + hasher.update(value.as_bytes()); + let digest = hasher.finalize(); + let mut hex = String::with_capacity(64); + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + hex.push(char::from(DIGITS[usize::from(byte >> 4)])); + hex.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + format!("cmtraceopen.{domain}.sha256.v1:{hex}") +} + +fn uuid_is_exact(value: &str) -> bool { + value.len() == 36 + && value.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +fn safe_operation(value: &str) -> bool { + value.strip_prefix("safe-operation-").is_some_and(|suffix| { + !suffix.is_empty() + && value.len() <= 96 + && suffix + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) +} + +fn layer_name(layer: ProviderAdminServiceLayer) -> &'static str { + match layer { + ProviderAdminServiceLayer::Provider => "provider", + ProviderAdminServiceLayer::AdminService => "adminService", + } +} + +fn display_layer(layer: ProviderAdminServiceLayer) -> &'static str { + match layer { + ProviderAdminServiceLayer::Provider => "Provider", + ProviderAdminServiceLayer::AdminService => "Admin Service", + } +} + +fn classification_name(classification: ProviderAdminServiceClassification) -> &'static str { + match classification { + ProviderAdminServiceClassification::Success => "success", + ProviderAdminServiceClassification::Recovered => "recovered", + ProviderAdminServiceClassification::ConfirmedFailure => "confirmed failure", + ProviderAdminServiceClassification::ContradictoryEvidence => "contradictory evidence", + ProviderAdminServiceClassification::BlockedOrDeferred => "blocked or deferred", + ProviderAdminServiceClassification::InsufficientEvidence => "insufficient evidence", + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs new file mode 100644 index 000000000..d7b9b3513 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -0,0 +1,2116 @@ +//! Role-local SCCM site-core and status analysis. +//! +//! This reducer consumes only the normalized server-intake assessment. It does +//! not reconstruct manifest state, inspect files, infer an installed role from +//! a default path, or correlate a client with a server by time. + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::models::log_entry::Severity; +use crate::sccm::{ + classify_artifact_name, SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, + SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFinding, SccmFindingBuilder, + SccmFindingClass, SccmFindingCoverageGap, SccmPhase, SccmRole, SccmTerminalEvidence, + SccmTimeOrderingState, SccmTimestamp, +}; + +use super::intake::CoverageIdentityKey; +use super::{ + SccmServerArtifactAssessment, SccmServerConfiguredPathState, SccmServerIntakeAssessment, +}; + +pub const SCCM_SITE_CORE_ANALYSIS_SCHEMA_VERSION: u32 = 1; +pub const SCCM_SITE_CORE_PROFILE_ID: &str = "sccm-site-core"; +pub const SCCM_SITE_CORE_PROFILE_VERSION: u32 = 1; +pub const SCCM_SITE_CORE_PROFILE_STABILITY: &str = "experimental"; +pub const SCCM_SITE_CORE_COMPONENT_GROUP: &str = "server-sitecomp"; +pub const SCCM_SITE_CORE_STATUS_GROUP: &str = "server-status"; + +const SITE_CORE_PROFILE_VERSION_TOKEN: &str = "5.00.TEST"; +const RECAPTURE_FLOOR_BYTES: u64 = 4096; +const MAX_SITE_CORE_REQUEST_ARTIFACTS: usize = 2; +const INTAKE_AUTHORITY_ARTIFACT_ID: &str = "site-core-intake-authority"; +const INTAKE_AUTHORITY_SOURCE_ID: &str = "server-site-core-intake"; +const INTAKE_AUTHORITY_REASON_CODE: &str = "intake-authority-invalid"; + +const STATE_CHAIN: [SccmSiteCorePhase; 5] = [ + SccmSiteCorePhase::ComponentStart, + SccmSiteCorePhase::ComponentWork, + SccmSiteCorePhase::InboxOrQueue, + SccmSiteCorePhase::StatusOrStateProcessing, + SccmSiteCorePhase::HealthyOrTerminal, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCoreWorkflow { + SiteCore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCorePhase { + ComponentStart, + ComponentWork, + InboxOrQueue, + StatusOrStateProcessing, + HealthyOrTerminal, +} + +impl SccmSiteCorePhase { + fn serialized_name(self) -> &'static str { + match self { + Self::ComponentStart => "componentStart", + Self::ComponentWork => "componentWork", + Self::InboxOrQueue => "inboxOrQueue", + Self::StatusOrStateProcessing => "statusOrStateProcessing", + Self::HealthyOrTerminal => "healthyOrTerminal", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCoreState { + Healthy, + TerminalFailure, + BlockedOrDeferred, + Recovered, + Incomplete, + Contradictory, + ParseGap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCoreConfidence { + None, + Low, + Moderate, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCoreDiagnosticMeaning { + CoverageOnly, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreProfile { + pub id: String, + pub version: u32, + pub stability: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreTransactionKey { + pub profile_id: String, + pub profile_version: u32, + pub site_handle: String, + pub producer_host_handle: String, + pub component_id: String, + pub work_item_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreEvidence { + pub artifact_id: String, + pub entry_id: String, + pub line_start: u32, + pub line_end: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub terminal: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub complete_logical_record: Option, +} + +impl SccmSiteCoreEvidence { + fn reference(&self) -> SccmEvidenceRef { + SccmEvidenceRef { + artifact_id: self.artifact_id.clone(), + entry_id: self.entry_id.clone(), + line_start: Some(self.line_start), + line_end: Some(self.line_end), + } + } + + fn sort_key(&self) -> (&str, u32, u32, &str) { + ( + self.artifact_id.as_str(), + self.line_start, + self.line_end, + self.entry_id.as_str(), + ) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreRequestScope { + #[serde(skip_serializing_if = "Option::is_none")] + pub producer_host_handle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub component_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub work_item_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rotation_lineage_handle: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreArtifactCandidate { + pub basename: String, + pub rotation: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreArtifactRequest { + pub logical_name: String, + pub role: SccmRole, + pub reason_code: String, + pub candidates: Vec, + pub max_artifacts: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_bytes_per_artifact: Option, + pub scope: SccmSiteCoreRequestScope, +} + +impl SccmSiteCoreArtifactRequest { + fn sort_key(&self) -> (&str, &str, &str, &SccmSiteCoreRequestScope) { + ( + self.logical_name.as_str(), + role_sort_key(&self.role), + self.reason_code.as_str(), + &self.scope, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreResult { + pub result_id: String, + pub transaction_key: SccmSiteCoreTransactionKey, + pub state: SccmSiteCoreState, + pub last_successful_phase: Option, + pub finding_class: Option, + pub confidence: SccmSiteCoreConfidence, + pub confidence_ceiling: SccmSiteCoreConfidence, + pub evidence: Vec, + pub coverage_gap_artifact_ids: Vec, + pub next_artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreObservation { + pub observation_id: String, + pub state: SccmSiteCoreState, + pub finding_class: SccmFindingClass, + pub confidence: SccmSiteCoreConfidence, + pub evidence: Vec, + pub coverage_gap_artifact_ids: Vec, + pub next_artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreCoverageGap { + pub artifact_id: String, + pub source_id: String, + pub state: SccmCoverageState, + pub reason_code: String, + pub diagnostic_meaning: SccmSiteCoreDiagnosticMeaning, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreFinding { + #[serde(flatten)] + pub finding: SccmFinding, + pub subject_id: String, + pub last_successful_phase: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreAnalysis { + pub schema_version: u32, + pub workflow: SccmSiteCoreWorkflow, + pub profile: SccmSiteCoreProfile, + pub state_chain: Vec, + pub results: Vec, + pub unlinked_observations: Vec, + pub coverage_gaps: Vec, + pub findings: Vec, + pub artifact_requests: Vec, + pub cross_side_correlation_performed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SiteCoreGroup { + Component, + Status, +} + +impl SiteCoreGroup { + fn source_id(self) -> &'static str { + match self { + Self::Component => SCCM_SITE_CORE_COMPONENT_GROUP, + Self::Status => SCCM_SITE_CORE_STATUS_GROUP, + } + } + + fn family(self) -> SccmArtifactFamily { + match self { + Self::Component => SccmArtifactFamily::SiteComponent, + Self::Status => SccmArtifactFamily::SiteStatus, + } + } + + fn from_source_id(value: &str) -> Option { + match value { + SCCM_SITE_CORE_COMPONENT_GROUP => Some(Self::Component), + SCCM_SITE_CORE_STATUS_GROUP => Some(Self::Status), + _ => None, + } + } +} + +#[derive(Clone, Copy)] +struct AdmittedSource<'a> { + artifact: &'a SccmServerArtifactAssessment, + group: SiteCoreGroup, + fact_eligible: bool, + rejection_reason: Option<&'static str>, +} + +struct SiteCoreContext<'a> { + artifacts: &'a [SccmServerArtifactAssessment], + sources: BTreeMap<&'a str, AdmittedSource<'a>>, + intake_authority_is_bound: bool, + evidence_identity_is_unique: Vec, + coverage_gaps: Vec, + coverage_gap_producer_hosts: BTreeMap, +} + +impl<'a> SiteCoreContext<'a> { + fn new(intake: &'a SccmServerIntakeAssessment) -> Self { + let intake_authority_is_bound = intake.adapter_authority_is_intake_bound(); + if !intake_authority_is_bound { + // The public assessment fields are no longer authoritative once the + // private intake seal fails. Keep the coverage failure explicit, but + // do not use caller-mutable artifact identities or topology to scope + // a collection request. + return Self { + artifacts: &[], + sources: BTreeMap::new(), + intake_authority_is_bound, + evidence_identity_is_unique: Vec::new(), + coverage_gaps: vec![SccmSiteCoreCoverageGap { + artifact_id: INTAKE_AUTHORITY_ARTIFACT_ID.to_owned(), + source_id: INTAKE_AUTHORITY_SOURCE_ID.to_owned(), + state: SccmCoverageState::ParseFailed, + reason_code: INTAKE_AUTHORITY_REASON_CODE.to_owned(), + diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, + }], + coverage_gap_producer_hosts: BTreeMap::new(), + }; + } + + let evidence_identity_is_unique = unique_evidence_identities(&intake.evidence); + let collision_artifact_ids = + evidence_collision_artifact_ids(&intake.evidence, &evidence_identity_is_unique); + let (evidence_source_rejections, unresolved_evidence_gaps) = + evidence_source_rejections(intake); + // Deliberate defense in depth: the complete adapter seal currently + // includes topology, while Site Core also keeps its topology-specific + // authority contract explicit at the point that topology scopes facts. + let coverage_congruent = + intake.topology_authority_is_intake_bound() && site_core_coverage_is_congruent(intake); + let sources = admitted_sources( + intake, + &collision_artifact_ids, + &evidence_source_rejections, + coverage_congruent, + ); + let mut coverage_gaps = collect_coverage_gaps(intake, &sources); + coverage_gaps.extend(unresolved_evidence_gaps); + sort_and_dedup_coverage_gaps(&mut coverage_gaps); + Self { + artifacts: &intake.artifacts, + sources, + intake_authority_is_bound, + evidence_identity_is_unique, + coverage_gaps, + coverage_gap_producer_hosts: BTreeMap::new(), + } + } + + fn add_undeclared_peer_source_gaps( + &mut self, + grouped: &BTreeMap>, + ) { + for (observed_group, required_group, reason_code) in [ + ( + SiteCoreGroup::Component, + SiteCoreGroup::Status, + "required-status-source-not-declared", + ), + ( + SiteCoreGroup::Status, + SiteCoreGroup::Component, + "required-component-source-not-declared", + ), + ] { + let producer_hosts = grouped + .iter() + .filter(|(_, facts)| facts.iter().any(|fact| fact.marker.group == observed_group)) + .map(|(key, _)| key.producer_host_handle.clone()) + .collect::>(); + for producer_host_handle in producer_hosts { + let compatible_source_exists = self.sources.values().any(|source| { + source.group == required_group + && source.artifact.producer_role == SccmRole::SiteServer + && source.artifact.producer_host_handle.as_deref() + == Some(producer_host_handle.as_str()) + && source.artifact.workflow_subject_role.is_none() + && source.artifact.workflow_subject_handle.is_none() + && (source.fact_eligible + || self.coverage_gaps.iter().any(|gap| { + gap.artifact_id == source.artifact.artifact_id + && gap.source_id == source.artifact.source_id + })) + }); + if compatible_source_exists { + continue; + } + let artifact_id = stable_opaque_id( + "site-core:missing-source:v1:", + &[required_group.source_id(), &producer_host_handle], + ); + self.coverage_gap_producer_hosts + .insert(artifact_id.clone(), producer_host_handle); + self.coverage_gaps.push(SccmSiteCoreCoverageGap { + artifact_id, + source_id: required_group.source_id().to_owned(), + state: SccmCoverageState::Absent, + reason_code: reason_code.to_owned(), + diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, + }); + } + } + sort_and_dedup_coverage_gaps(&mut self.coverage_gaps); + } +} + +pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAnalysis { + let mut context = SiteCoreContext::new(intake); + let mut grouped = BTreeMap::>::new(); + let mut record_observations = Vec::new(); + if context.intake_authority_is_bound { + for (position, evidence) in intake.evidence.iter().enumerate() { + let Some(source) = context.sources.get(evidence.reference.artifact_id.as_str()) else { + continue; + }; + if let Some(reason_code) = evidence_record_rejection_reason(evidence, source.group) { + if is_profile_record_candidate(&evidence.message) { + record_observations.push(rejected_record_observation( + evidence, + source, + reason_code, + )); + } + continue; + } + if !source.fact_eligible || !context.evidence_identity_is_unique[position] { + continue; + } + match parse_fact(evidence, source, &intake.topology.site_handle) { + ProfileRecordParse::Accepted(fact) => { + grouped.entry(fact.key.clone()).or_default().push(*fact); + } + ProfileRecordParse::Rejected(reason_code) => { + record_observations.push(rejected_record_observation( + evidence, + source, + reason_code, + )); + } + ProfileRecordParse::NotCandidate => {} + } + } + } + context.add_undeclared_peer_source_gaps(&grouped); + + let mut results = Vec::new(); + let mut findings = Vec::new(); + for (key, mut facts) in grouped { + facts.sort_by(compare_facts); + let gap_ids = coverage_gap_ids_for_key(&context, &key); + let reduced = reduce_transaction(key, &facts, &context, &gap_ids); + if let Some(class) = reduced.finding_class.clone() { + if let Some(finding) = build_result_finding(&reduced, class, &facts, &context) { + findings.push(finding); + } + } + results.push(reduced); + } + + results.sort_by(|left, right| left.result_id.cmp(&right.result_id)); + findings.sort_by(|left, right| { + left.subject_id + .cmp(&right.subject_id) + .then_with(|| left.finding.finding_id.cmp(&right.finding.finding_id)) + }); + + let mut unlinked_observations = coverage_observations(&context.coverage_gaps, &context); + unlinked_observations.extend(record_observations); + unlinked_observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + unlinked_observations.dedup_by(|left, right| left.observation_id == right.observation_id); + for observation in &unlinked_observations { + if let Some(finding) = build_observation_finding(observation, &context) { + findings.push(finding); + } + } + findings.sort_by(|left, right| { + left.subject_id + .cmp(&right.subject_id) + .then_with(|| left.finding.finding_id.cmp(&right.finding.finding_id)) + }); + + let mut artifact_requests = results + .iter() + .flat_map(|result| result.next_artifacts.iter()) + .chain( + unlinked_observations + .iter() + .flat_map(|observation| observation.next_artifacts.iter()), + ) + .cloned() + .collect::>(); + artifact_requests.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())); + artifact_requests.dedup(); + + SccmSiteCoreAnalysis { + schema_version: SCCM_SITE_CORE_ANALYSIS_SCHEMA_VERSION, + workflow: SccmSiteCoreWorkflow::SiteCore, + profile: SccmSiteCoreProfile { + id: SCCM_SITE_CORE_PROFILE_ID.to_owned(), + version: SCCM_SITE_CORE_PROFILE_VERSION, + stability: SCCM_SITE_CORE_PROFILE_STABILITY.to_owned(), + }, + state_chain: STATE_CHAIN.to_vec(), + results, + unlinked_observations, + coverage_gaps: context.coverage_gaps, + findings, + artifact_requests, + cross_side_correlation_performed: false, + } +} + +fn admitted_sources<'a>( + intake: &'a SccmServerIntakeAssessment, + collision_artifact_ids: &BTreeSet, + evidence_source_rejections: &BTreeMap, + coverage_congruent: bool, +) -> BTreeMap<&'a str, AdmittedSource<'a>> { + let mut occurrences = BTreeMap::<&str, usize>::new(); + for artifact in &intake.artifacts { + *occurrences + .entry(artifact.artifact_id.as_str()) + .or_default() += 1; + } + + intake + .artifacts + .iter() + .filter_map(|artifact| { + let group = SiteCoreGroup::from_source_id(&artifact.source_id)?; + if occurrences.get(artifact.artifact_id.as_str()) != Some(&1) { + return None; + } + let shape_valid = source_shape_is_valid(artifact, group); + let rejection_reason = if artifact.producer_role != SccmRole::SiteServer + || artifact.workflow_subject_role.is_some() + || artifact.workflow_subject_handle.is_some() + { + Some("source-role-or-subject-rejected") + } else if !coverage_congruent { + Some("intake-coverage-incongruent") + } else if collision_artifact_ids.contains(&artifact.artifact_id) { + Some("evidence-identity-collision") + } else if let Some(reason_code) = evidence_source_rejections.get(&artifact.artifact_id) + { + Some(*reason_code) + } else if !shape_valid { + Some("source-shape-invalid") + } else if artifact.state != SccmCoverageState::Captured { + Some(coverage_rejection_reason(&artifact.state)) + } else if !source_carries_facts(artifact) { + Some("source-profile-or-provenance-unusable") + } else { + None + }; + Some(( + artifact.artifact_id.as_str(), + AdmittedSource { + artifact, + group, + fact_eligible: rejection_reason.is_none(), + rejection_reason, + }, + )) + }) + .collect() +} + +fn evidence_source_rejections( + intake: &SccmServerIntakeAssessment, +) -> (BTreeMap, Vec) { + let mut groups_by_artifact = BTreeMap::<&str, Vec>::new(); + let mut artifact_ids = BTreeSet::<&str>::new(); + for artifact in &intake.artifacts { + artifact_ids.insert(artifact.artifact_id.as_str()); + if let Some(group) = SiteCoreGroup::from_source_id(&artifact.source_id) { + groups_by_artifact + .entry(artifact.artifact_id.as_str()) + .or_default() + .push(group); + } + } + + let mut source_rejections = BTreeMap::::new(); + let mut unresolved_gaps = Vec::new(); + for evidence in &intake.evidence { + match groups_by_artifact.get(evidence.reference.artifact_id.as_str()) { + Some(groups) if groups.len() == 1 => { + if let Some(reason_code) = evidence_record_rejection_reason(evidence, groups[0]) { + source_rejections + .entry(evidence.reference.artifact_id.clone()) + .and_modify(|current| { + if reason_code < *current { + *current = reason_code; + } + }) + .or_insert(reason_code); + } + } + None if is_profile_record_candidate(&evidence.message) => { + let Some(group) = evidence_component_group(evidence.component.as_deref()) else { + continue; + }; + unresolved_gaps.push(SccmSiteCoreCoverageGap { + artifact_id: unresolved_coverage_artifact_id( + evidence, + artifact_ids.contains(evidence.reference.artifact_id.as_str()), + ), + source_id: group.source_id().to_owned(), + state: SccmCoverageState::ParseFailed, + reason_code: "evidence-source-unresolved".to_owned(), + diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, + }); + } + _ => {} + } + } + sort_and_dedup_coverage_gaps(&mut unresolved_gaps); + (source_rejections, unresolved_gaps) +} + +fn evidence_record_rejection_reason( + evidence: &SccmEvidence, + source_group: SiteCoreGroup, +) -> Option<&'static str> { + if evidence.role != SccmRole::SiteServer { + return Some("evidence-role-rejected"); + } + if !reference_is_complete(evidence) { + return Some("evidence-reference-rejected"); + } + evidence_component_group(evidence.component.as_deref()) + .is_some_and(|evidence_group| evidence_group != source_group) + .then_some("evidence-source-attribution-rejected") +} + +fn evidence_component_group(component: Option<&str>) -> Option { + match component? { + "SMS_SITE_COMPONENT_MANAGER" | "SMS_HIERARCHY_MANAGER" => Some(SiteCoreGroup::Component), + "SMS_STATUS_MANAGER" | "SMS_STATE_SYSTEM" => Some(SiteCoreGroup::Status), + _ => None, + } +} + +fn unresolved_coverage_artifact_id(evidence: &SccmEvidence, is_foreign_source: bool) -> String { + if !is_foreign_source && safe_site_core_opaque_id(&evidence.reference.artifact_id) { + evidence.reference.artifact_id.clone() + } else { + stable_opaque_id( + "site-core:rejected-artifact:v1:", + &[&evidence.reference.artifact_id, &evidence.evidence_id], + ) + } +} + +fn coverage_rejection_reason(state: &SccmCoverageState) -> &'static str { + match state { + SccmCoverageState::Captured => "source-contract-rejected", + SccmCoverageState::Absent => "required-source-absent", + SccmCoverageState::AccessDenied => "required-source-access-denied", + SccmCoverageState::Capped => "required-source-capped", + SccmCoverageState::Skipped => "required-source-skipped", + SccmCoverageState::Unsupported => "required-source-unsupported", + SccmCoverageState::ParseFailed => "required-source-parse-failed", + } +} + +fn source_shape_is_valid(artifact: &SccmServerArtifactAssessment, group: SiteCoreGroup) -> bool { + let Some(basename) = artifact.original_basename.as_deref() else { + return false; + }; + let classified = classify_artifact_name(basename, SccmRole::SiteServer); + let validated_logical_source = match group { + SiteCoreGroup::Component => matches!(classified.logical_name.as_str(), "sitecomp" | "hman"), + SiteCoreGroup::Status => matches!(classified.logical_name.as_str(), "statmgr" | "statesys"), + }; + validated_logical_source + && safe_site_core_opaque_id(&artifact.artifact_id) + && safe_site_core_opaque_id(&artifact.rotation_lineage_handle) + && artifact.source_id == group.source_id() + && artifact.family == group.family() + && artifact.rotation.as_ref() == Some(&classified.rotation) + && classified.supported_for_diagnosis + && classified.family == group.family() + && classified.role == SccmRole::SiteServer + && artifact.parser_eligible + && artifact + .producer_host_handle + .as_deref() + .is_some_and(|host| { + !host.is_empty() + && host.len() <= 256 + && host.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'-' | b'_') + }) + }) +} + +fn expected_evidence_component(artifact: &SccmServerArtifactAssessment) -> Option<&'static str> { + let basename = artifact.original_basename.as_deref()?; + let classified = classify_artifact_name(basename, SccmRole::SiteServer); + Some(match classified.logical_name.as_str() { + "sitecomp" => "SMS_SITE_COMPONENT_MANAGER", + "hman" => "SMS_HIERARCHY_MANAGER", + "statmgr" => "SMS_STATUS_MANAGER", + "statesys" => "SMS_STATE_SYSTEM", + _ => return None, + }) +} + +fn source_carries_facts(artifact: &SccmServerArtifactAssessment) -> bool { + let provenance_is_usable = artifact + .capture_provenance + .as_ref() + .is_some_and(|provenance| { + provenance.schema_version == 1 + && provenance.encoding == "utf-8" + && !provenance.limit_applied + && provenance.byte_limit >= artifact.bytes_copied + && provenance.byte_limit > 0 + }); + artifact.state == SccmCoverageState::Captured + && artifact.profile_eligible + && artifact.source_version.as_deref() == Some(SITE_CORE_PROFILE_VERSION_TOKEN) + && artifact.fragment_complete != Some(false) + && artifact.truncated != Some(true) + && artifact.bytes_copied > 0 + && artifact.relative_path.is_some() + && artifact.content_sha256.as_deref().is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + && provenance_is_usable +} + +fn coverage_gap_ids_for_key( + context: &SiteCoreContext<'_>, + key: &SccmSiteCoreTransactionKey, +) -> Vec { + context + .coverage_gaps + .iter() + .filter(|gap| { + context + .coverage_gap_producer_hosts + .get(&gap.artifact_id) + .is_some_and(|producer| producer == &key.producer_host_handle) + || context + .sources + .get(gap.artifact_id.as_str()) + .is_some_and(|source| { + source.artifact.producer_host_handle.as_deref() + == Some(key.producer_host_handle.as_str()) + }) + }) + .map(|gap| gap.artifact_id.clone()) + .collect() +} + +fn collect_coverage_gaps( + intake: &SccmServerIntakeAssessment, + sources: &BTreeMap<&str, AdmittedSource<'_>>, +) -> Vec { + let mut occurrences = BTreeMap::<&str, usize>::new(); + for artifact in &intake.artifacts { + if SiteCoreGroup::from_source_id(&artifact.source_id).is_some() { + *occurrences + .entry(artifact.artifact_id.as_str()) + .or_default() += 1; + } + } + + let mut gaps = Vec::new(); + for artifact in &intake.artifacts { + let Some(_group) = SiteCoreGroup::from_source_id(&artifact.source_id) else { + continue; + }; + let duplicate_identity = occurrences.get(artifact.artifact_id.as_str()) != Some(&1); + let source = sources.get(artifact.artifact_id.as_str()); + if !duplicate_identity + && source.is_some_and(|source| { + source.fact_eligible || absent_default_is_superseded(source.artifact, sources) + }) + { + continue; + } + let reason_code = if duplicate_identity { + "duplicate-source-identity" + } else { + source + .and_then(|source| source.rejection_reason) + .unwrap_or("source-contract-rejected") + }; + let state = if duplicate_identity + || matches!( + reason_code, + "source-role-or-subject-rejected" + | "intake-coverage-incongruent" + | "evidence-identity-collision" + | "evidence-reference-rejected" + | "evidence-role-rejected" + | "evidence-source-attribution-rejected" + | "source-shape-invalid" + | "source-contract-rejected" + ) { + SccmCoverageState::ParseFailed + } else if artifact.state == SccmCoverageState::Captured { + if artifact.fragment_complete == Some(false) || artifact.truncated == Some(true) { + SccmCoverageState::ParseFailed + } else { + SccmCoverageState::Unsupported + } + } else { + artifact.state.clone() + }; + let artifact_id = if safe_site_core_opaque_id(&artifact.artifact_id) { + artifact.artifact_id.clone() + } else { + stable_opaque_id( + "site-core:rejected-artifact:v1:", + &[&artifact.artifact_id, &artifact.source_id], + ) + }; + gaps.push(SccmSiteCoreCoverageGap { + artifact_id, + source_id: artifact.source_id.clone(), + state, + reason_code: reason_code.to_owned(), + diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, + }); + } + sort_and_dedup_coverage_gaps(&mut gaps); + gaps +} + +fn sort_and_dedup_coverage_gaps(gaps: &mut Vec) { + gaps.sort_by(|left, right| { + left.artifact_id + .cmp(&right.artifact_id) + .then_with(|| left.source_id.cmp(&right.source_id)) + .then_with(|| coverage_sort_key(&left.state).cmp(coverage_sort_key(&right.state))) + .then_with(|| left.reason_code.cmp(&right.reason_code)) + }); + gaps.dedup_by(|left, right| { + left.artifact_id == right.artifact_id + && left.source_id == right.source_id + && left.state == right.state + && left.reason_code == right.reason_code + }); +} + +fn absent_default_is_superseded( + artifact: &SccmServerArtifactAssessment, + sources: &BTreeMap<&str, AdmittedSource<'_>>, +) -> bool { + artifact.state == SccmCoverageState::Absent + && artifact.configured_path_state == SccmServerConfiguredPathState::DefaultCandidate + && sources.values().any(|candidate| { + candidate.fact_eligible + && candidate.artifact.artifact_id != artifact.artifact_id + && candidate.artifact.source_id == artifact.source_id + && candidate.artifact.producer_role == artifact.producer_role + && candidate.artifact.producer_host_handle == artifact.producer_host_handle + && candidate.artifact.workflow_subject_role == artifact.workflow_subject_role + && candidate.artifact.workflow_subject_handle == artifact.workflow_subject_handle + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FactOutcome { + Succeeded, + Failed, + Deferred, +} + +impl FactOutcome { + fn token(self) -> &'static str { + match self { + Self::Succeeded => "success", + Self::Failed => "failure", + Self::Deferred => "deferred", + } + } +} + +#[derive(Debug, Clone, Copy)] +struct StatusMarker { + phase: SccmSiteCorePhase, + outcome: FactOutcome, + terminal: bool, + recovery: bool, + group: SiteCoreGroup, +} + +fn status_marker(value: &str) -> Option { + Some(match value { + "SC_COMPONENT_START_OK" => StatusMarker { + phase: SccmSiteCorePhase::ComponentStart, + outcome: FactOutcome::Succeeded, + terminal: false, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_COMPONENT_WORK_OK" => StatusMarker { + phase: SccmSiteCorePhase::ComponentWork, + outcome: FactOutcome::Succeeded, + terminal: false, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_INBOX_ACCEPTED" => StatusMarker { + phase: SccmSiteCorePhase::InboxOrQueue, + outcome: FactOutcome::Succeeded, + terminal: false, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_INBOX_BACKLOG" => StatusMarker { + phase: SccmSiteCorePhase::InboxOrQueue, + outcome: FactOutcome::Deferred, + terminal: false, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_COMPONENT_TERMINAL_FAILURE" => StatusMarker { + phase: SccmSiteCorePhase::HealthyOrTerminal, + outcome: FactOutcome::Failed, + terminal: true, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_STATUS_PROCESSING_OK" => StatusMarker { + phase: SccmSiteCorePhase::StatusOrStateProcessing, + outcome: FactOutcome::Succeeded, + terminal: false, + recovery: false, + group: SiteCoreGroup::Status, + }, + "SC_STATUS_TERMINAL_FAILURE" => StatusMarker { + phase: SccmSiteCorePhase::HealthyOrTerminal, + outcome: FactOutcome::Failed, + terminal: true, + recovery: false, + group: SiteCoreGroup::Status, + }, + "SC_COMPONENT_HEALTHY" => StatusMarker { + phase: SccmSiteCorePhase::HealthyOrTerminal, + outcome: FactOutcome::Succeeded, + terminal: true, + recovery: false, + group: SiteCoreGroup::Status, + }, + "SC_COMPONENT_RECOVERED" => StatusMarker { + phase: SccmSiteCorePhase::HealthyOrTerminal, + outcome: FactOutcome::Succeeded, + terminal: true, + recovery: true, + group: SiteCoreGroup::Status, + }, + _ => return None, + }) +} + +#[derive(Debug, Clone)] +struct SiteCoreFact { + key: SccmSiteCoreTransactionKey, + marker: StatusMarker, + reference: SccmEvidenceRef, + timestamp: SccmTimestamp, +} + +impl SiteCoreFact { + fn ordering_millis(&self) -> Option { + (self.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc) + .then_some(self.timestamp.utc_millis) + .flatten() + } + + fn public_evidence(&self) -> SccmSiteCoreEvidence { + SccmSiteCoreEvidence { + artifact_id: self.reference.artifact_id.clone(), + entry_id: self.reference.entry_id.clone(), + line_start: self.reference.line_start.unwrap_or_default(), + line_end: self.reference.line_end.unwrap_or_default(), + terminal: match self.marker.outcome { + FactOutcome::Failed if self.marker.terminal => Some(true), + FactOutcome::Deferred => Some(false), + _ if self.marker.recovery => Some(true), + _ => None, + }, + recovery: self.marker.recovery.then_some(true), + complete_logical_record: None, + } + } +} + +enum ProfileRecordParse { + NotCandidate, + Rejected(&'static str), + Accepted(Box), +} + +fn parse_fact( + evidence: &SccmEvidence, + source: &AdmittedSource<'_>, + site_handle: &str, +) -> ProfileRecordParse { + let message = evidence.message.as_str(); + if !is_profile_record_candidate(message) { + return ProfileRecordParse::NotCandidate; + } + if evidence.component.as_deref() != expected_evidence_component(source.artifact) { + return ProfileRecordParse::Rejected("profile-component-source-mismatch"); + } + if !profile_labels_are_closed(message) { + return ProfileRecordParse::Rejected("profile-field-schema-rejected"); + } + let Some(profile_id) = token_value(message, "profileId") else { + return ProfileRecordParse::Rejected("profile-identity-missing"); + }; + let Some(profile_version) = token_value(message, "profileVersion") else { + return ProfileRecordParse::Rejected("profile-version-missing"); + }; + let Some(site) = token_value(message, "site") else { + return ProfileRecordParse::Rejected("profile-site-missing"); + }; + if profile_id != SCCM_SITE_CORE_PROFILE_ID + || profile_version != SCCM_SITE_CORE_PROFILE_VERSION.to_string() + || site_handle != "synthetic:site:lab" + || site != "LAB" + { + return ProfileRecordParse::Rejected("profile-identity-rejected"); + } + + let Some(component_id) = + token_value(message, "componentId").and_then(|value| validated_component_id(&value)) + else { + return ProfileRecordParse::Rejected("profile-component-id-rejected"); + }; + let Some(work_item_id) = + token_value(message, "workItemId").and_then(|value| validated_work_item_id(&value)) + else { + return ProfileRecordParse::Rejected("profile-work-item-id-rejected"); + }; + let Some(marker) = token_value(message, "statusId").and_then(|value| status_marker(&value)) + else { + return ProfileRecordParse::Rejected("profile-status-id-rejected"); + }; + let Some(outcome) = token_value(message, "outcome") else { + return ProfileRecordParse::Rejected("profile-outcome-missing"); + }; + let Some(terminal) = token_value(message, "terminal") else { + return ProfileRecordParse::Rejected("profile-terminal-missing"); + }; + if marker.group != source.group + || outcome != marker.outcome.token() + || terminal != if marker.terminal { "true" } else { "false" } + || !queue_depth_matches_marker(message, marker) + { + return ProfileRecordParse::Rejected("profile-status-schema-rejected"); + } + + ProfileRecordParse::Accepted(Box::new(SiteCoreFact { + key: SccmSiteCoreTransactionKey { + profile_id: SCCM_SITE_CORE_PROFILE_ID.to_owned(), + profile_version: SCCM_SITE_CORE_PROFILE_VERSION, + site_handle: site_handle.to_owned(), + producer_host_handle: source + .artifact + .producer_host_handle + .clone() + .expect("fact-eligible sources have a validated producer host"), + component_id, + work_item_id, + }, + marker, + reference: evidence.reference.clone(), + timestamp: evidence.timestamp.clone(), + })) +} + +fn reduce_transaction( + key: SccmSiteCoreTransactionKey, + facts: &[SiteCoreFact], + context: &SiteCoreContext<'_>, + coverage_gap_artifact_ids: &[String], +) -> SccmSiteCoreResult { + let comparable = facts.iter().all(|fact| fact.ordering_millis().is_some()); + let contradictory = comparable && has_same_instant_conflict(facts); + let successes = facts + .iter() + .filter(|fact| fact.marker.outcome == FactOutcome::Succeeded) + .collect::>(); + let last_successful_phase = facts + .iter() + .rev() + .find(|fact| fact.marker.outcome == FactOutcome::Succeeded) + .map(|fact| fact.marker.phase); + let last_terminal = facts.iter().rev().find(|fact| fact.marker.terminal); + let has_prior_failure = last_terminal.is_some_and(|terminal| { + terminal.marker.outcome == FactOutcome::Succeeded + && facts.iter().any(|fact| { + fact.marker.terminal + && fact.marker.outcome == FactOutcome::Failed + && fact + .ordering_millis() + .zip(terminal.ordering_millis()) + .is_some_and(|(failure, recovery)| failure < recovery) + }) + }); + let has_deferred = has_unrecovered_deferred(facts); + let has_component_progress = successes.iter().any(|fact| { + matches!( + fact.marker.phase, + SccmSiteCorePhase::ComponentStart | SccmSiteCorePhase::ComponentWork + ) + }); + let terminal_is_last = last_terminal.is_some_and(|terminal| { + terminal.ordering_millis().is_some_and(|terminal_time| { + facts.iter().all(|fact| { + std::ptr::eq(fact, terminal) + || fact + .ordering_millis() + .is_some_and(|fact_time| fact_time < terminal_time) + }) + }) + }); + let success_progress_is_ordered = + last_terminal.is_some_and(|terminal| observed_success_progress_is_ordered(facts, terminal)); + let full_success_chain = + last_terminal.is_some_and(|terminal| complete_success_chain_is_ordered(facts, terminal)); + + let (state, finding_class, confidence) = if contradictory { + ( + SccmSiteCoreState::Contradictory, + Some(SccmFindingClass::Symptom), + SccmSiteCoreConfidence::Low, + ) + } else if !comparable { + ( + SccmSiteCoreState::Incomplete, + Some(SccmFindingClass::InsufficientEvidence), + SccmSiteCoreConfidence::None, + ) + } else if let Some(terminal) = last_terminal { + match terminal.marker.outcome { + FactOutcome::Failed + if terminal_is_last && has_component_progress && success_progress_is_ordered => + { + ( + SccmSiteCoreState::TerminalFailure, + Some(SccmFindingClass::ConfirmedFailure), + SccmSiteCoreConfidence::High, + ) + } + FactOutcome::Succeeded + if terminal_is_last && terminal.marker.recovery && has_prior_failure => + { + ( + SccmSiteCoreState::Recovered, + Some(SccmFindingClass::Symptom), + SccmSiteCoreConfidence::High, + ) + } + FactOutcome::Succeeded + if terminal_is_last && !terminal.marker.recovery && full_success_chain => + { + ( + SccmSiteCoreState::Healthy, + None, + SccmSiteCoreConfidence::High, + ) + } + _ => ( + SccmSiteCoreState::Incomplete, + Some(SccmFindingClass::InsufficientEvidence), + SccmSiteCoreConfidence::None, + ), + } + } else if has_deferred { + ( + SccmSiteCoreState::BlockedOrDeferred, + Some(SccmFindingClass::BlockedOrDeferred), + SccmSiteCoreConfidence::Low, + ) + } else { + ( + SccmSiteCoreState::Incomplete, + Some(SccmFindingClass::InsufficientEvidence), + SccmSiteCoreConfidence::None, + ) + }; + + let mut evidence = facts + .iter() + .map(SiteCoreFact::public_evidence) + .collect::>(); + evidence.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())); + evidence.dedup(); + let next_artifacts = next_artifacts_for_state(state, &key, facts, context); + let result_id = stable_opaque_id( + "site-core:result:v1:", + &[ + &key.site_handle, + &key.producer_host_handle, + &key.component_id, + &key.work_item_id, + ], + ); + + SccmSiteCoreResult { + result_id, + transaction_key: key, + state, + last_successful_phase, + finding_class, + confidence, + confidence_ceiling: confidence, + evidence, + coverage_gap_artifact_ids: coverage_gap_artifact_ids.to_vec(), + next_artifacts, + } +} + +fn has_unrecovered_deferred(facts: &[SiteCoreFact]) -> bool { + facts + .iter() + .filter(|fact| fact.marker.outcome == FactOutcome::Deferred) + .any(|deferred| { + let Some(deferred_time) = deferred.ordering_millis() else { + return true; + }; + !facts.iter().any(|candidate| { + candidate.marker.phase == deferred.marker.phase + && candidate.marker.outcome == FactOutcome::Succeeded + && candidate + .ordering_millis() + .is_some_and(|success_time| success_time > deferred_time) + }) + }) +} + +fn observed_success_progress_is_ordered(facts: &[SiteCoreFact], terminal: &SiteCoreFact) -> bool { + let Some(terminal_time) = terminal.ordering_millis() else { + return false; + }; + let mut previous_phase = None; + let mut observed = false; + for fact in facts.iter().filter(|fact| { + !std::ptr::eq(*fact, terminal) && fact.marker.outcome == FactOutcome::Succeeded + }) { + let Some(instant) = fact.ordering_millis() else { + return false; + }; + if instant >= terminal_time || previous_phase.is_some_and(|phase| fact.marker.phase < phase) + { + return false; + } + previous_phase = Some(fact.marker.phase); + observed = true; + } + observed +} + +fn complete_success_chain_is_ordered(facts: &[SiteCoreFact], terminal: &SiteCoreFact) -> bool { + let Some(terminal_time) = terminal.ordering_millis() else { + return false; + }; + let mut previous_time = None; + for phase in &STATE_CHAIN[..4] { + let Some(instant) = facts + .iter() + .filter(|fact| { + fact.marker.outcome == FactOutcome::Succeeded && fact.marker.phase == *phase + }) + .filter_map(SiteCoreFact::ordering_millis) + .find(|instant| { + *instant < terminal_time && previous_time.is_none_or(|previous| *instant > previous) + }) + else { + return false; + }; + previous_time = Some(instant); + } + true +} + +fn has_same_instant_conflict(facts: &[SiteCoreFact]) -> bool { + let mut outcomes = BTreeMap::<(i64, SccmSiteCorePhase), FactOutcome>::new(); + facts.iter().any(|fact| { + let Some(instant) = fact.ordering_millis() else { + return false; + }; + outcomes + .insert((instant, fact.marker.phase), fact.marker.outcome) + .is_some_and(|previous| previous != fact.marker.outcome) + }) +} + +fn next_artifacts_for_state( + state: SccmSiteCoreState, + key: &SccmSiteCoreTransactionKey, + facts: &[SiteCoreFact], + context: &SiteCoreContext<'_>, +) -> Vec { + if state == SccmSiteCoreState::BlockedOrDeferred { + return vec![status_request( + "matching-status-terminal-evidence-missing", + key, + )]; + } + if state != SccmSiteCoreState::Incomplete { + return Vec::new(); + } + + if let Some(source) = context.sources.values().find(|source| { + source.artifact.state == SccmCoverageState::Capped + && facts + .iter() + .any(|fact| fact.reference.artifact_id == source.artifact.artifact_id) + }) { + return recapture_request(source.artifact, Some(key)) + .into_iter() + .collect(); + } + if facts + .iter() + .any(|fact| fact.marker.group == SiteCoreGroup::Component) + && !facts + .iter() + .any(|fact| fact.marker.group == SiteCoreGroup::Status) + { + return vec![status_request("matching-status-evidence-missing", key)]; + } + if facts + .iter() + .any(|fact| fact.marker.group == SiteCoreGroup::Status) + && !facts + .iter() + .any(|fact| fact.marker.group == SiteCoreGroup::Component) + { + return vec![component_request( + "matching-component-evidence-missing", + key, + )]; + } + Vec::new() +} + +fn status_request( + reason_code: &str, + key: &SccmSiteCoreTransactionKey, +) -> SccmSiteCoreArtifactRequest { + matching_group_request(SiteCoreGroup::Status, reason_code, key) +} + +fn component_request( + reason_code: &str, + key: &SccmSiteCoreTransactionKey, +) -> SccmSiteCoreArtifactRequest { + matching_group_request(SiteCoreGroup::Component, reason_code, key) +} + +fn matching_group_request( + group: SiteCoreGroup, + reason_code: &str, + key: &SccmSiteCoreTransactionKey, +) -> SccmSiteCoreArtifactRequest { + group_request( + group, + reason_code, + SccmSiteCoreRequestScope { + producer_host_handle: Some(key.producer_host_handle.clone()), + component_id: Some(key.component_id.clone()), + work_item_id: Some(key.work_item_id.clone()), + rotation_lineage_handle: None, + }, + ) +} + +fn recapture_request( + artifact: &SccmServerArtifactAssessment, + key: Option<&SccmSiteCoreTransactionKey>, +) -> Option { + let candidate = request_candidate(artifact)?; + let scope = request_scope_for_artifact(artifact, key)?; + let current_limit = artifact + .capture_provenance + .as_ref() + .map(|provenance| provenance.byte_limit) + .unwrap_or(RECAPTURE_FLOOR_BYTES); + let requested = current_limit.saturating_mul(2).max(RECAPTURE_FLOOR_BYTES); + let bounded = requested.checked_next_power_of_two().unwrap_or(1u64 << 63); + Some(SccmSiteCoreArtifactRequest { + logical_name: artifact.source_id.clone(), + role: SccmRole::SiteServer, + reason_code: "capped-before-next-phase".to_owned(), + candidates: vec![candidate], + max_artifacts: 1, + max_bytes_per_artifact: Some(bounded), + scope, + }) +} + +fn coverage_observations( + gaps: &[SccmSiteCoreCoverageGap], + context: &SiteCoreContext<'_>, +) -> Vec { + gaps.iter() + .map(|gap| { + let request = coverage_request(gap, context).into_iter().collect(); + SccmSiteCoreObservation { + observation_id: stable_opaque_id( + "site-core:observation:v1:", + &[ + &gap.artifact_id, + &gap.source_id, + coverage_sort_key(&gap.state), + &gap.reason_code, + ], + ), + state: SccmSiteCoreState::ParseGap, + finding_class: SccmFindingClass::InsufficientEvidence, + confidence: SccmSiteCoreConfidence::None, + evidence: Vec::new(), + coverage_gap_artifact_ids: vec![gap.artifact_id.clone()], + next_artifacts: request, + } + }) + .collect() +} + +fn rejected_record_observation( + evidence: &SccmEvidence, + source: &AdmittedSource<'_>, + reason_code: &str, +) -> SccmSiteCoreObservation { + let retained_evidence = reference_is_complete(evidence).then(|| SccmSiteCoreEvidence { + artifact_id: evidence.reference.artifact_id.clone(), + entry_id: evidence.reference.entry_id.clone(), + line_start: evidence + .reference + .line_start + .expect("complete reference start"), + line_end: evidence.reference.line_end.expect("complete reference end"), + terminal: None, + recovery: None, + complete_logical_record: Some(true), + }); + let request = complete_source_request(source.artifact, reason_code).or_else(|| { + request_scope_for_artifact(source.artifact, None) + .map(|scope| group_request(source.group, reason_code, scope)) + }); + SccmSiteCoreObservation { + observation_id: stable_opaque_id( + "site-core:observation:v1:", + &[ + &evidence.reference.artifact_id, + &evidence.reference.entry_id, + reason_code, + ], + ), + state: SccmSiteCoreState::ParseGap, + finding_class: SccmFindingClass::Symptom, + confidence: SccmSiteCoreConfidence::Low, + evidence: retained_evidence.into_iter().collect(), + coverage_gap_artifact_ids: Vec::new(), + next_artifacts: request.into_iter().collect(), + } +} + +fn coverage_request( + gap: &SccmSiteCoreCoverageGap, + context: &SiteCoreContext<'_>, +) -> Option { + let group = SiteCoreGroup::from_source_id(&gap.source_id)?; + if let Some(producer_host_handle) = context.coverage_gap_producer_hosts.get(&gap.artifact_id) { + let scope = SccmSiteCoreRequestScope { + producer_host_handle: Some(producer_host_handle.clone()), + component_id: None, + work_item_id: None, + rotation_lineage_handle: None, + }; + return request_scope_is_specific(&scope) + .then(|| group_request(group, &gap.reason_code, scope)); + } + if let Some(source) = context.sources.get(gap.artifact_id.as_str()) { + if gap.state == SccmCoverageState::Capped { + if let Some(request) = recapture_request(source.artifact, None) { + return Some(request); + } + } else if let Some(request) = complete_source_request(source.artifact, &gap.reason_code) { + return Some(request); + } + let scope = request_scope_for_artifact(source.artifact, None)?; + return Some(group_request(group, &gap.reason_code, scope)); + } + let scope = request_scope_for_gap(gap, context)?; + Some(group_request(group, &gap.reason_code, scope)) +} + +fn complete_source_request( + artifact: &SccmServerArtifactAssessment, + reason_code: &str, +) -> Option { + let candidate = request_candidate(artifact)?; + let scope = request_scope_for_artifact(artifact, None)?; + Some(SccmSiteCoreArtifactRequest { + logical_name: artifact.source_id.clone(), + role: SccmRole::SiteServer, + reason_code: reason_code.to_owned(), + candidates: vec![candidate], + max_artifacts: 1, + max_bytes_per_artifact: None, + scope, + }) +} + +fn group_request( + group: SiteCoreGroup, + reason_code: &str, + scope: SccmSiteCoreRequestScope, +) -> SccmSiteCoreArtifactRequest { + let stem = match group { + SiteCoreGroup::Component => "sitecomp", + SiteCoreGroup::Status => "statmgr", + }; + SccmSiteCoreArtifactRequest { + logical_name: group.source_id().to_owned(), + role: SccmRole::SiteServer, + reason_code: reason_code.to_owned(), + candidates: vec![ + SccmSiteCoreArtifactCandidate { + basename: format!("{stem}.log"), + rotation: "current".to_owned(), + }, + SccmSiteCoreArtifactCandidate { + basename: format!("{stem}.lo_"), + rotation: "loUnderscore".to_owned(), + }, + ], + max_artifacts: MAX_SITE_CORE_REQUEST_ARTIFACTS, + max_bytes_per_artifact: None, + scope, + } +} + +fn request_scope_for_gap( + gap: &SccmSiteCoreCoverageGap, + context: &SiteCoreContext<'_>, +) -> Option { + let exact_artifacts = context + .artifacts + .iter() + .filter(|artifact| { + artifact.artifact_id == gap.artifact_id && artifact.source_id == gap.source_id + }) + .collect::>(); + let artifacts = if exact_artifacts.is_empty() { + context + .artifacts + .iter() + .filter(|artifact| artifact.source_id == gap.source_id) + .collect() + } else { + exact_artifacts + }; + consensus_request_scope(&artifacts) +} + +fn request_scope_for_artifact( + artifact: &SccmServerArtifactAssessment, + key: Option<&SccmSiteCoreTransactionKey>, +) -> Option { + let producer_host_handle = key + .map(|key| key.producer_host_handle.as_str()) + .or(artifact.producer_host_handle.as_deref()) + .filter(|value| safe_site_core_opaque_id(value)) + .map(str::to_owned); + let component_id = key + .and_then(|key| validated_component_id(&key.component_id)) + .filter(|value| safe_site_core_opaque_id(value)); + let work_item_id = key + .and_then(|key| validated_work_item_id(&key.work_item_id)) + .filter(|value| safe_site_core_opaque_id(value)); + let rotation_lineage_handle = safe_site_core_opaque_id(&artifact.rotation_lineage_handle) + .then(|| artifact.rotation_lineage_handle.clone()); + let scope = SccmSiteCoreRequestScope { + producer_host_handle, + component_id, + work_item_id, + rotation_lineage_handle, + }; + request_scope_is_specific(&scope).then_some(scope) +} + +fn consensus_request_scope( + artifacts: &[&SccmServerArtifactAssessment], +) -> Option { + let producer_host_handle = consensus_scope_value(artifacts, |artifact| { + artifact.producer_host_handle.as_deref() + }); + let rotation_lineage_handle = consensus_scope_value(artifacts, |artifact| { + Some(artifact.rotation_lineage_handle.as_str()) + }); + let scope = SccmSiteCoreRequestScope { + producer_host_handle, + component_id: None, + work_item_id: None, + rotation_lineage_handle, + }; + request_scope_is_specific(&scope).then_some(scope) +} + +fn consensus_scope_value( + artifacts: &[&SccmServerArtifactAssessment], + value: impl Fn(&SccmServerArtifactAssessment) -> Option<&str>, +) -> Option { + let first = value(*artifacts.first()?)?; + (safe_site_core_opaque_id(first) + && artifacts.iter().all(|artifact| { + value(artifact) + .is_some_and(|candidate| candidate == first && safe_site_core_opaque_id(candidate)) + })) + .then(|| first.to_owned()) +} + +fn request_scope_is_specific(scope: &SccmSiteCoreRequestScope) -> bool { + scope + .producer_host_handle + .as_deref() + .is_some_and(safe_site_core_opaque_id) + || scope + .component_id + .as_deref() + .is_some_and(safe_site_core_opaque_id) + || scope + .work_item_id + .as_deref() + .is_some_and(safe_site_core_opaque_id) + || scope + .rotation_lineage_handle + .as_deref() + .is_some_and(safe_site_core_opaque_id) +} + +fn request_candidate( + artifact: &SccmServerArtifactAssessment, +) -> Option { + let basename = artifact.original_basename.as_ref()?; + let rotation = artifact.rotation.as_ref()?; + let classified = classify_artifact_name(basename, SccmRole::SiteServer); + (classified.supported_for_diagnosis + && classified.role == SccmRole::SiteServer + && classified.family == artifact.family + && &classified.rotation == rotation) + .then(|| SccmSiteCoreArtifactCandidate { + basename: basename.clone(), + rotation: rotation_name(rotation).expect("classified rotations are declared"), + }) +} + +fn build_result_finding( + result: &SccmSiteCoreResult, + class: SccmFindingClass, + facts: &[SiteCoreFact], + context: &SiteCoreContext<'_>, +) -> Option { + let terminal_evidence = if class == SccmFindingClass::ConfirmedFailure { + facts + .iter() + .rev() + .find(|fact| fact.marker.terminal && fact.marker.outcome == FactOutcome::Failed) + .map(|fact| { + vec![SccmTerminalEvidence::observed_failure( + fact.reference.clone(), + )] + }) + .unwrap_or_default() + } else { + Vec::new() + }; + let finding = SccmFindingBuilder::new(stable_opaque_id( + "site-core:finding:v1:", + &[&result.result_id], + )) + .class(class) + .phase(SccmPhase::Unknown( + result + .last_successful_phase + .map(SccmSiteCorePhase::serialized_name) + .unwrap_or("siteCoreUnconfirmed") + .to_owned(), + )) + .role(SccmRole::SiteServer) + .severity(if result.state == SccmSiteCoreState::TerminalFailure { + Severity::Error + } else { + Severity::Warning + }) + .confidence(shared_confidence(result.confidence)) + .title("Site component and status evidence") + .summary(match result.last_successful_phase { + Some(phase) => format!( + "The last confirmed successful phase is {}; later phases are bounded to cited evidence.", + phase.serialized_name() + ), + None => "No site component phase is confirmed by the cited evidence.".to_owned(), + }) + .evidence( + result + .evidence + .iter() + .map(SccmSiteCoreEvidence::reference) + .collect(), + ) + .terminal_evidence(terminal_evidence) + .coverage_gaps(finding_gaps( + &result.coverage_gap_artifact_ids, + context, + )) + .next_artifacts(shared_requests(&result.next_artifacts)) + .build() + .ok()?; + Some(SccmSiteCoreFinding { + finding, + subject_id: result.result_id.clone(), + last_successful_phase: result.last_successful_phase, + }) +} + +fn build_observation_finding( + observation: &SccmSiteCoreObservation, + context: &SiteCoreContext<'_>, +) -> Option { + let is_coverage_gap = !observation.coverage_gap_artifact_ids.is_empty(); + let (phase, title, summary) = if is_coverage_gap { + ( + "siteCoreCoverage", + "Site core coverage gap", + "The source is incomplete and cannot establish a component outcome.", + ) + } else { + ( + "siteCoreProfile", + "Unrecognized site core profile record", + "A source-local record was retained as a symptom but did not match the selected extraction profile.", + ) + }; + let finding = SccmFindingBuilder::new(stable_opaque_id( + "site-core:finding:v1:", + &[&observation.observation_id], + )) + .class(observation.finding_class.clone()) + .phase(SccmPhase::Unknown(phase.to_owned())) + .role(SccmRole::SiteServer) + .severity(Severity::Warning) + .confidence(shared_confidence(observation.confidence)) + .title(title) + .summary(summary) + .evidence( + observation + .evidence + .iter() + .map(SccmSiteCoreEvidence::reference) + .collect(), + ) + .coverage_gaps(finding_gaps( + &observation.coverage_gap_artifact_ids, + context, + )) + .next_artifacts(shared_requests(&observation.next_artifacts)) + .build() + .ok()?; + Some(SccmSiteCoreFinding { + finding, + subject_id: observation.observation_id.clone(), + last_successful_phase: None, + }) +} + +fn finding_gaps( + artifact_ids: &[String], + context: &SiteCoreContext<'_>, +) -> Vec { + context + .coverage_gaps + .iter() + .filter(|gap| artifact_ids.contains(&gap.artifact_id)) + .map(|gap| SccmFindingCoverageGap { + artifact_id: gap.artifact_id.clone(), + role: SccmRole::SiteServer, + coverage: gap.state.clone(), + }) + .collect() +} + +fn shared_requests(requests: &[SccmSiteCoreArtifactRequest]) -> Vec { + let mut shared = requests + .iter() + .flat_map(|request| request.candidates.iter()) + .filter_map(|candidate| { + let classified = classify_artifact_name(&candidate.basename, SccmRole::SiteServer); + classified + .supported_for_diagnosis + .then(|| SccmArtifactRequest { + logical_id: classified.logical_name, + role: SccmRole::SiteServer, + reason: format!("Collect the complete {} file.", classified.basename), + }) + }) + .collect::>(); + shared.sort_by(|left, right| { + left.logical_id + .cmp(&right.logical_id) + .then_with(|| left.reason.cmp(&right.reason)) + }); + shared.dedup(); + shared +} + +fn shared_confidence(confidence: SccmSiteCoreConfidence) -> SccmConfidence { + match confidence { + SccmSiteCoreConfidence::None => SccmConfidence::None, + SccmSiteCoreConfidence::Low => SccmConfidence::Low, + SccmSiteCoreConfidence::Moderate => SccmConfidence::Moderate, + SccmSiteCoreConfidence::High => SccmConfidence::High, + } +} + +fn validated_token_value(message: &str, label: &str) -> Option> { + let lowercase = message.to_ascii_lowercase(); + let needle = format!("{}=", label.to_ascii_lowercase()); + let mut value = None; + for (label_start, _) in lowercase.match_indices(&needle) { + let exact_boundary = label_start == 0 + || message[..label_start] + .chars() + .next_back() + .is_some_and(is_token_boundary); + if !exact_boundary { + return None; + } + let remainder = &message[label_start + needle.len()..]; + let end = remainder.find(is_token_boundary).unwrap_or(remainder.len()); + if end == 0 || value.replace(remainder[..end].to_owned()).is_some() { + return None; + } + } + Some(value) +} + +fn token_value(message: &str, label: &str) -> Option { + validated_token_value(message, label)? +} + +fn is_token_boundary(character: char) -> bool { + character.is_whitespace() || matches!(character, ',' | ';' | '&') +} + +fn is_profile_record_candidate(message: &str) -> bool { + let lowercase = message.to_ascii_lowercase(); + lowercase.contains("profileid=") || lowercase.contains("statusid=sc_") +} + +fn profile_labels_are_closed(message: &str) -> bool { + message.split(is_token_boundary).all(|token| { + let Some((label, _)) = token.split_once('=') else { + return true; + }; + matches!( + label.to_ascii_lowercase().as_str(), + "profileid" + | "profileversion" + | "site" + | "componentid" + | "workitemid" + | "statusid" + | "outcome" + | "terminal" + | "queuedepth" + ) + }) +} + +fn validated_component_id(value: &str) -> Option { + matches!(value, "SMS_EXECUTIVE" | "SMS_DISTRIBUTION_MANAGER").then(|| value.to_owned()) +} + +fn validated_work_item_id(value: &str) -> Option { + let suffix = value.strip_prefix("SC-")?; + (!suffix.is_empty() + && value.len() <= 64 + && suffix.split('-').all(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + })) + .then(|| value.to_owned()) +} + +fn queue_depth_matches_marker(message: &str, marker: StatusMarker) -> bool { + let Some(queue_depth) = validated_token_value(message, "queueDepth") else { + return false; + }; + match (marker.outcome, queue_depth) { + (FactOutcome::Deferred, Some(value)) => value + .parse::() + .is_ok_and(|depth| (1..=1_000_000).contains(&depth)), + (FactOutcome::Deferred, None) => false, + (_, None) => true, + (_, Some(_)) => false, + } +} + +fn reference_is_complete(evidence: &SccmEvidence) -> bool { + safe_site_core_opaque_id(&evidence.evidence_id) + && safe_site_core_opaque_id(&evidence.reference.artifact_id) + && safe_site_core_opaque_id(&evidence.reference.entry_id) + && evidence.evidence_id == evidence.reference.entry_id + && matches!( + (evidence.reference.line_start, evidence.reference.line_end), + (Some(start), Some(end)) if start > 0 && end >= start + ) +} + +fn unique_evidence_identities(evidence: &[SccmEvidence]) -> Vec { + let mut unique = vec![true; evidence.len()]; + mark_repeated_keys( + &mut unique, + evidence.iter().map(|record| record.evidence_id.as_str()), + ); + mark_repeated_keys( + &mut unique, + evidence + .iter() + .map(|record| record.reference.entry_id.as_str()), + ); + mark_overlapping_ranges(&mut unique, evidence); + unique +} + +fn evidence_collision_artifact_ids( + evidence: &[SccmEvidence], + identity_is_unique: &[bool], +) -> BTreeSet { + evidence + .iter() + .zip(identity_is_unique) + .filter(|(_, unique)| !**unique) + .map(|(record, _)| record.reference.artifact_id.clone()) + .collect() +} + +fn site_core_coverage_is_congruent(intake: &SccmServerIntakeAssessment) -> bool { + let mut expected = BTreeMap::>::new(); + for artifact in &intake.artifacts { + if SiteCoreGroup::from_source_id(&artifact.source_id).is_none() { + continue; + } + expected + .entry(CoverageIdentityKey::from_artifact(artifact)) + .or_default() + .push(artifact.artifact_id.clone()); + } + + let mut observed = BTreeMap::>::new(); + for coverage in &intake.coverage { + if SiteCoreGroup::from_source_id(&coverage.source_id).is_none() { + continue; + } + observed + .entry(CoverageIdentityKey::from_coverage(coverage)) + .or_default() + .extend(coverage.artifact_ids.iter().cloned()); + } + for artifact_ids in expected.values_mut().chain(observed.values_mut()) { + artifact_ids.sort(); + } + expected == observed +} + +fn safe_site_core_opaque_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && value.trim() == value + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-')) +} + +fn mark_repeated_keys<'a>(unique: &mut [bool], keys: impl Iterator) { + let mut positions = BTreeMap::<&str, Vec>::new(); + for (position, key) in keys.enumerate() { + positions.entry(key).or_default().push(position); + } + for repeated in positions + .into_values() + .filter(|positions| positions.len() > 1) + { + for position in repeated { + unique[position] = false; + } + } +} + +fn mark_overlapping_ranges(unique: &mut [bool], evidence: &[SccmEvidence]) { + let mut by_artifact = BTreeMap::<&str, Vec<(u32, u32, usize)>>::new(); + for (position, record) in evidence.iter().enumerate() { + if let (Some(start), Some(end)) = (record.reference.line_start, record.reference.line_end) { + by_artifact + .entry(record.reference.artifact_id.as_str()) + .or_default() + .push((start, end, position)); + } + } + for ranges in by_artifact.values_mut() { + ranges.sort_unstable(); + let mut active: Option<(u32, usize)> = None; + for &(start, end, position) in ranges.iter() { + if let Some((active_end, active_position)) = active { + if start <= active_end { + unique[position] = false; + unique[active_position] = false; + } + } + if active.is_none_or(|(active_end, _)| end > active_end) { + active = Some((end, position)); + } + } + } +} + +fn compare_facts(left: &SiteCoreFact, right: &SiteCoreFact) -> Ordering { + left.ordering_millis() + .cmp(&right.ordering_millis()) + .then_with(|| left.marker.phase.cmp(&right.marker.phase)) + .then_with(|| compare_references(&left.reference, &right.reference)) +} + +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)) +} + +fn rotation_name(rotation: &crate::sccm::SccmRotation) -> Option { + Some(match rotation { + crate::sccm::SccmRotation::Current => "current".to_owned(), + crate::sccm::SccmRotation::LoUnderscore => "loUnderscore".to_owned(), + crate::sccm::SccmRotation::Numbered(value) => format!("numbered-{value}"), + crate::sccm::SccmRotation::Timestamped(value) => format!("timestamped-{value}"), + crate::sccm::SccmRotation::Unknown(_) => return None, + }) +} + +fn coverage_sort_key(state: &SccmCoverageState) -> &'static str { + match state { + SccmCoverageState::Captured => "captured", + SccmCoverageState::Absent => "absent", + SccmCoverageState::AccessDenied => "accessDenied", + SccmCoverageState::Capped => "capped", + SccmCoverageState::Skipped => "skipped", + SccmCoverageState::Unsupported => "unsupported", + SccmCoverageState::ParseFailed => "parseFailed", + } +} + +fn role_sort_key(role: &SccmRole) -> &str { + match role { + SccmRole::Client => "client", + SccmRole::SiteServer => "siteServer", + SccmRole::ManagementPoint => "managementPoint", + SccmRole::DistributionPoint => "distributionPoint", + SccmRole::SoftwareUpdatePoint => "softwareUpdatePoint", + SccmRole::WsUs => "wsUs", + SccmRole::Provider => "provider", + SccmRole::AdminService => "adminService", + SccmRole::Unknown(value) => value, + } +} + +fn stable_opaque_id(prefix: &str, parts: &[&str]) -> String { + const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + + let mut digest = Sha256::new(); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part.as_bytes()); + } + let digest = digest.finalize(); + let mut encoded = String::with_capacity(prefix.len() + digest.len() * 2); + encoded.push_str(prefix); + for byte in digest { + encoded.push(char::from(LOWER_HEX[usize::from(byte >> 4)])); + encoded.push(char::from(LOWER_HEX[usize::from(byte & 0x0f)])); + } + encoded +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs new file mode 100644 index 000000000..0b8e6bdec --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs @@ -0,0 +1,1002 @@ +//! Server-local Software Update Point and WSUS workflow analysis. +//! +//! The reducer consumes only the integrity-bound canonical server intake. It +//! does not consume client analyzer output and deliberately leaves cross-side +//! correlation to issue #333. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +use crate::sccm::{ + SccmCoverageState, SccmEvidence, SccmRole, SccmTimeOrderingState, SccmTimestamp, +}; + +use super::{SccmServerArtifactAssessment, SccmServerIntakeAssessment}; + +pub const SCCM_SOFTWARE_UPDATE_POINT_SYNC_SOURCE_ID: &str = "server-sup-sync"; +pub const SCCM_SOFTWARE_UPDATE_POINT_WSUS_SOURCE_ID: &str = "server-sup-wsus"; +pub const SCCM_SOFTWARE_UPDATE_POINT_PROFILE_ID: &str = "sup-server-5.00.test-v1"; +pub const SCCM_SOFTWARE_UPDATE_POINT_SOURCE_VERSION: &str = "5.00.TEST.0001"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointWorkflow { + SoftwareUpdatePoint, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointPhase { + Configure, + Synchronize, + ImportOrProcessMetadata, + ValidateWsus, + PublishAvailability, + HealthyOrTerminal, +} + +impl SccmSoftwareUpdatePointPhase { + fn rank(self) -> usize { + match self { + Self::Configure => 0, + Self::Synchronize => 1, + Self::ImportOrProcessMetadata => 2, + Self::ValidateWsus => 3, + Self::PublishAvailability => 4, + Self::HealthyOrTerminal => 5, + } + } + + fn observation_suffix(self, disposition: SccmSoftwareUpdatePointDisposition) -> &'static str { + if disposition == SccmSoftwareUpdatePointDisposition::Retrying { + return "retry"; + } + match self { + Self::Configure => "configure", + Self::Synchronize => "synchronize", + Self::ImportOrProcessMetadata => "import", + Self::ValidateWsus => "validate", + Self::PublishAvailability => "publish", + Self::HealthyOrTerminal => "terminal", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointDisposition { + Succeeded, + Failed, + Retrying, + Deferred, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointState { + Succeeded, + Failed, + Deferred, + Incomplete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointClassification { + Success, + ConfirmedFailure, + BlockedOrDeferred, + InsufficientEvidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointConfidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointKeyConfidence { + Exact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointTopologyCompatibility { + Exact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointProfileSelection { + SelectedSynthetic, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointMissingPathInterpretation { + SourceCoverageOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointSourceLocalClassification { + RotationSplit, + MalformedEvidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSoftwareUpdatePointRequestReason { + CoverageAbsent, + CoverageAccessDenied, + CoverageCapped, + CoverageMalformed, + CoverageRotationSplit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointAnalysisContract { + pub independent_reducer: bool, + pub consumes_client_output: bool, + pub cross_side_correlation_performed: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointExtractionProfile { + pub selection_state: SccmSoftwareUpdatePointProfileSelection, + pub profile_id: Option, + pub validated_role: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointRoleAssessment { + pub software_update_point_observed: bool, + pub role_absent_inferred: bool, + pub missing_default_path_interpretation: SccmSoftwareUpdatePointMissingPathInterpretation, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointCoverage { + pub artifact_id: String, + pub state: SccmCoverageState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointKey { + pub sync_run_id: String, + pub site_code: String, + pub sup_handle: String, + pub update_id: Option, + pub kb_id: Option, + pub confidence: SccmSoftwareUpdatePointKeyConfidence, + pub extraction_profile_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointEvidence { + pub artifact_id: String, + pub start_line: u32, + pub end_line: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointObservation { + pub observation_id: String, + pub phase: SccmSoftwareUpdatePointPhase, + pub disposition: SccmSoftwareUpdatePointDisposition, + pub terminal: bool, + /// Comparable time retained for the #333 typed correlation adapter. The + /// accepted #330 source-local JSON contract predates correlation and must + /// remain byte-identical, so this canonical fact field is intentionally + /// excluded from that projection. + #[serde(skip_serializing)] + pub timestamp: SccmTimestamp, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointTransaction { + pub transaction_id: String, + pub key: SccmSoftwareUpdatePointKey, + pub topology_compatibility: SccmSoftwareUpdatePointTopologyCompatibility, + pub correlation_eligible: bool, + pub state: SccmSoftwareUpdatePointState, + pub classification: SccmSoftwareUpdatePointClassification, + pub confidence: SccmSoftwareUpdatePointConfidence, + pub confidence_ceiling: SccmSoftwareUpdatePointConfidence, + pub last_successful_phase: Option, + pub next_source_id: Option, + pub coverage_gap_artifact_ids: Vec, + pub observations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointSourceLocalObservation { + pub observation_id: String, + pub classification: SccmSoftwareUpdatePointSourceLocalClassification, + pub confidence: SccmSoftwareUpdatePointConfidence, + pub confidence_ceiling: SccmSoftwareUpdatePointConfidence, + pub correlation_eligible: bool, + pub artifact_ids: Vec, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointArtifactRequest { + pub sup_handle: String, + pub source_id: String, + pub reason_code: SccmSoftwareUpdatePointRequestReason, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointCorrelationHandoff { + pub issue: String, + pub performed: bool, + pub time_only_eligible: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSoftwareUpdatePointAnalysis { + pub workflow: SccmSoftwareUpdatePointWorkflow, + pub state_chain: Vec, + pub analysis_contract: SccmSoftwareUpdatePointAnalysisContract, + pub extraction_profile: SccmSoftwareUpdatePointExtractionProfile, + pub role_assessment: SccmSoftwareUpdatePointRoleAssessment, + pub coverage: Vec, + pub transactions: Vec, + pub source_local_observations: Vec, + pub artifact_requests: Vec, + pub client_causal_claims: Vec, + pub correlation_handoff: SccmSoftwareUpdatePointCorrelationHandoff, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct FactKey { + sync_run_id: String, + site_code: String, + sup_handle: String, + update_id: Option, + kb_id: Option, + profile_id: String, +} + +#[derive(Debug, Clone)] +struct Fact { + key: FactKey, + phase: SccmSoftwareUpdatePointPhase, + disposition: SccmSoftwareUpdatePointDisposition, + terminal: bool, + evidence: SccmSoftwareUpdatePointEvidence, + utc_millis: i64, +} + +enum ParsedFact { + Valid(Fact), + Poisoned(FactKey), +} + +pub fn analyze_software_update_point( + intake: &SccmServerIntakeAssessment, +) -> SccmSoftwareUpdatePointAnalysis { + if !intake.adapter_authority_is_intake_bound() || !intake.topology_authority_is_intake_bound() { + return empty_analysis(false); + } + + let scoped_artifacts = intake + .artifacts + .iter() + .filter(|artifact| is_scoped_artifact(artifact)) + .collect::>(); + let sup_observed = intake + .topology + .roles_observed + .contains(&SccmRole::SoftwareUpdatePoint); + let synthetic_profile_selected = scoped_artifacts + .iter() + .any(|artifact| artifact.source_id == SCCM_SOFTWARE_UPDATE_POINT_SYNC_SOURCE_ID) + && scoped_artifacts.iter().all(|artifact| { + artifact.source_id != SCCM_SOFTWARE_UPDATE_POINT_SYNC_SOURCE_ID + || (artifact.profile_eligible + && artifact.source_version.as_deref() + == Some(SCCM_SOFTWARE_UPDATE_POINT_SOURCE_VERSION)) + }); + + let mut coverage = scoped_artifacts + .iter() + .map(|artifact| SccmSoftwareUpdatePointCoverage { + artifact_id: artifact.artifact_id.clone(), + state: artifact.state.clone(), + }) + .collect::>(); + coverage.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + + let gap_artifacts = scoped_artifacts + .iter() + .copied() + .filter(|artifact| { + artifact.producer_role != SccmRole::Client + && (artifact.state != SccmCoverageState::Captured + || artifact.fragment_complete == Some(false)) + }) + .collect::>(); + let artifact_requests = artifact_requests(&gap_artifacts); + let source_local_observations = source_local_observations(&scoped_artifacts); + + let mut facts_by_key = BTreeMap::>::new(); + let mut poisoned_keys = BTreeSet::::new(); + for artifact in &scoped_artifacts { + if !synthetic_profile_selected || !artifact_admits_transaction_facts(artifact) { + continue; + } + for evidence in intake + .evidence + .iter() + .filter(|evidence| evidence.reference.artifact_id == artifact.artifact_id) + { + match parse_fact(intake, artifact, evidence) { + Some(ParsedFact::Valid(fact)) => { + facts_by_key.entry(fact.key.clone()).or_default().push(fact); + } + Some(ParsedFact::Poisoned(key)) => { + poisoned_keys.insert(key); + } + None => {} + } + } + } + + facts_by_key.retain(|key, _| !poisoned_keys.contains(key)); + reject_conflicting_transaction_keys(&mut facts_by_key); + let mut transactions = facts_by_key + .into_iter() + .filter_map(|(key, facts)| reduce_transaction(key, facts, &scoped_artifacts)) + .collect::>(); + transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_id)); + + SccmSoftwareUpdatePointAnalysis { + workflow: SccmSoftwareUpdatePointWorkflow::SoftwareUpdatePoint, + state_chain: state_chain(), + analysis_contract: analysis_contract(), + extraction_profile: extraction_profile(synthetic_profile_selected), + role_assessment: role_assessment(sup_observed), + coverage, + transactions, + source_local_observations, + artifact_requests, + client_causal_claims: Vec::new(), + correlation_handoff: correlation_handoff(), + } +} + +fn empty_analysis(sup_observed: bool) -> SccmSoftwareUpdatePointAnalysis { + SccmSoftwareUpdatePointAnalysis { + workflow: SccmSoftwareUpdatePointWorkflow::SoftwareUpdatePoint, + state_chain: state_chain(), + analysis_contract: analysis_contract(), + extraction_profile: extraction_profile(false), + role_assessment: role_assessment(sup_observed), + coverage: Vec::new(), + transactions: Vec::new(), + source_local_observations: Vec::new(), + artifact_requests: Vec::new(), + client_causal_claims: Vec::new(), + correlation_handoff: correlation_handoff(), + } +} + +fn state_chain() -> Vec { + vec![ + SccmSoftwareUpdatePointPhase::Configure, + SccmSoftwareUpdatePointPhase::Synchronize, + SccmSoftwareUpdatePointPhase::ImportOrProcessMetadata, + SccmSoftwareUpdatePointPhase::ValidateWsus, + SccmSoftwareUpdatePointPhase::PublishAvailability, + SccmSoftwareUpdatePointPhase::HealthyOrTerminal, + ] +} + +fn analysis_contract() -> SccmSoftwareUpdatePointAnalysisContract { + SccmSoftwareUpdatePointAnalysisContract { + independent_reducer: true, + consumes_client_output: false, + cross_side_correlation_performed: false, + } +} + +fn extraction_profile(selected: bool) -> SccmSoftwareUpdatePointExtractionProfile { + if selected { + SccmSoftwareUpdatePointExtractionProfile { + selection_state: SccmSoftwareUpdatePointProfileSelection::SelectedSynthetic, + profile_id: Some(SCCM_SOFTWARE_UPDATE_POINT_PROFILE_ID.to_owned()), + validated_role: Some(SccmRole::SoftwareUpdatePoint), + } + } else { + SccmSoftwareUpdatePointExtractionProfile { + selection_state: SccmSoftwareUpdatePointProfileSelection::Unavailable, + profile_id: None, + validated_role: None, + } + } +} + +fn role_assessment(sup_observed: bool) -> SccmSoftwareUpdatePointRoleAssessment { + SccmSoftwareUpdatePointRoleAssessment { + software_update_point_observed: sup_observed, + role_absent_inferred: false, + missing_default_path_interpretation: + SccmSoftwareUpdatePointMissingPathInterpretation::SourceCoverageOnly, + } +} + +fn correlation_handoff() -> SccmSoftwareUpdatePointCorrelationHandoff { + SccmSoftwareUpdatePointCorrelationHandoff { + issue: "#333".to_owned(), + performed: false, + time_only_eligible: false, + } +} + +fn is_scoped_artifact(artifact: &SccmServerArtifactAssessment) -> bool { + artifact.workflow_subject_role == Some(SccmRole::SoftwareUpdatePoint) + && matches!( + artifact.source_id.as_str(), + SCCM_SOFTWARE_UPDATE_POINT_SYNC_SOURCE_ID | SCCM_SOFTWARE_UPDATE_POINT_WSUS_SOURCE_ID + ) +} + +fn artifact_admits_transaction_facts(artifact: &SccmServerArtifactAssessment) -> bool { + artifact.source_id == SCCM_SOFTWARE_UPDATE_POINT_SYNC_SOURCE_ID + && artifact.state == SccmCoverageState::Captured + && artifact.fragment_complete != Some(false) + && artifact.parser_eligible + && artifact.profile_eligible + && artifact.source_version.as_deref() == Some(SCCM_SOFTWARE_UPDATE_POINT_SOURCE_VERSION) + && artifact.workflow_subject_role == Some(SccmRole::SoftwareUpdatePoint) + && artifact.workflow_subject_handle.is_some() + && matches!( + ( + artifact.producer_role.clone(), + artifact.original_basename.as_deref() + ), + (SccmRole::SiteServer, Some("WCM.log" | "wsyncmgr.log")) + | ( + SccmRole::SoftwareUpdatePoint, + Some("SUPSetup.log" | "WSUSCtrl.log") + ) + ) +} + +fn parse_fact( + intake: &SccmServerIntakeAssessment, + artifact: &SccmServerArtifactAssessment, + evidence: &SccmEvidence, +) -> Option { + let fields = parse_fixture_fields(&evidence.message)?; + if fields.contains_key("ClientHandle") { + return None; + } + let phase = parse_phase(fields.get("Phase")?)?; + if !phase_allowed_for_artifact(artifact.original_basename.as_deref()?, phase) { + return None; + } + let disposition = parse_disposition(fields.get("Disposition")?)?; + let terminal = match fields.get("Terminal")?.as_str() { + "true" => true, + "false" => false, + _ => return None, + }; + if !coherent_disposition(phase, disposition, terminal) { + return None; + } + + let sync_run_id = fields.get("SyncRunId")?.clone(); + let site_code = fields.get("SiteCode")?.clone(); + let sup_handle = fields.get("SupHandle")?.clone(); + let profile_id = fields.get("ProfileId")?.clone(); + if !site_code_is_topology_compatible(&site_code, &intake.topology.site_handle) + || artifact.workflow_subject_handle.as_deref() != Some(sup_handle.as_str()) + || profile_id != SCCM_SOFTWARE_UPDATE_POINT_PROFILE_ID + { + return None; + } + let (update_id, kb_id) = match (fields.get("UpdateId"), fields.get("KbId")) { + (Some(update_id), Some(kb_id)) => (Some(update_id.clone()), Some(kb_id.clone())), + (None, None) => (None, None), + _ => return None, + }; + let key = FactKey { + sync_run_id, + site_code, + sup_handle, + update_id, + kb_id, + profile_id, + }; + let utc_millis = match ( + &evidence.timestamp.ordering_state, + evidence.timestamp.utc_millis, + ) { + (SccmTimeOrderingState::NormalizedUtc, Some(utc_millis)) => utc_millis, + _ => return Some(ParsedFact::Poisoned(key)), + }; + let start_line = evidence.reference.line_start?; + let end_line = evidence.reference.line_end?; + if start_line == 0 || end_line < start_line { + return None; + } + + Some(ParsedFact::Valid(Fact { + key, + phase, + disposition, + terminal, + evidence: SccmSoftwareUpdatePointEvidence { + artifact_id: evidence.reference.artifact_id.clone(), + start_line, + end_line, + }, + utc_millis, + })) +} + +fn site_code_is_topology_compatible(site_code: &str, site_handle: &str) -> bool { + site_code == site_handle || (site_code == "LAB" && site_handle == "synthetic:site:lab") +} + +fn parse_fixture_fields(message: &str) -> Option> { + let message = message.strip_prefix("[sccm-public-message-v1] ")?; + let mut segments = message.split(';').map(str::trim); + if segments.next()? != "SYNTHETIC FIXTURE" { + return None; + } + let allowed = [ + "Phase", + "Disposition", + "Terminal", + "SyncRunId", + "SiteCode", + "SupHandle", + "ProfileId", + "UpdateId", + "KbId", + "ClientHandle", + ]; + let mut fields = BTreeMap::new(); + for segment in segments { + let (name, value) = segment.split_once('=')?; + if !allowed.contains(&name) + || value.is_empty() + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-') + }) + || fields.insert(name, value.to_owned()).is_some() + { + return None; + } + } + Some(fields) +} + +fn parse_phase(value: &str) -> Option { + Some(match value { + "configure" => SccmSoftwareUpdatePointPhase::Configure, + "synchronize" => SccmSoftwareUpdatePointPhase::Synchronize, + "importOrProcessMetadata" => SccmSoftwareUpdatePointPhase::ImportOrProcessMetadata, + "validateWsus" => SccmSoftwareUpdatePointPhase::ValidateWsus, + "publishAvailability" => SccmSoftwareUpdatePointPhase::PublishAvailability, + "healthyOrTerminal" => SccmSoftwareUpdatePointPhase::HealthyOrTerminal, + _ => return None, + }) +} + +fn parse_disposition(value: &str) -> Option { + Some(match value { + "succeeded" => SccmSoftwareUpdatePointDisposition::Succeeded, + "failed" => SccmSoftwareUpdatePointDisposition::Failed, + "retrying" => SccmSoftwareUpdatePointDisposition::Retrying, + "deferred" => SccmSoftwareUpdatePointDisposition::Deferred, + _ => return None, + }) +} + +fn coherent_disposition( + phase: SccmSoftwareUpdatePointPhase, + disposition: SccmSoftwareUpdatePointDisposition, + terminal: bool, +) -> bool { + match (disposition, terminal) { + (SccmSoftwareUpdatePointDisposition::Succeeded, true) => { + phase == SccmSoftwareUpdatePointPhase::HealthyOrTerminal + } + (SccmSoftwareUpdatePointDisposition::Succeeded, false) => true, + (SccmSoftwareUpdatePointDisposition::Failed, true) => true, + ( + SccmSoftwareUpdatePointDisposition::Retrying + | SccmSoftwareUpdatePointDisposition::Deferred, + false, + ) => true, + _ => false, + } +} + +fn phase_allowed_for_artifact(basename: &str, phase: SccmSoftwareUpdatePointPhase) -> bool { + matches!( + (basename, phase), + ("WCM.log", SccmSoftwareUpdatePointPhase::Configure) + | ( + "wsyncmgr.log", + SccmSoftwareUpdatePointPhase::Synchronize + | SccmSoftwareUpdatePointPhase::ImportOrProcessMetadata + | SccmSoftwareUpdatePointPhase::PublishAvailability + | SccmSoftwareUpdatePointPhase::HealthyOrTerminal + ) + | ( + "SUPSetup.log", + SccmSoftwareUpdatePointPhase::Configure + | SccmSoftwareUpdatePointPhase::HealthyOrTerminal + ) + | ( + "WSUSCtrl.log", + SccmSoftwareUpdatePointPhase::ValidateWsus + | SccmSoftwareUpdatePointPhase::HealthyOrTerminal + ) + ) +} + +fn reject_conflicting_transaction_keys(facts_by_key: &mut BTreeMap>) { + let mut base_key_counts = BTreeMap::<(String, String, String, String), usize>::new(); + for key in facts_by_key.keys() { + *base_key_counts + .entry(( + key.sync_run_id.clone(), + key.site_code.clone(), + key.sup_handle.clone(), + key.profile_id.clone(), + )) + .or_default() += 1; + } + facts_by_key.retain(|key, _| { + base_key_counts.get(&( + key.sync_run_id.clone(), + key.site_code.clone(), + key.sup_handle.clone(), + key.profile_id.clone(), + )) == Some(&1) + }); +} + +fn reduce_transaction( + key: FactKey, + mut facts: Vec, + scoped_artifacts: &[&SccmServerArtifactAssessment], +) -> Option { + facts.sort_by(|left, right| { + (left.utc_millis, left.phase.rank()).cmp(&(right.utc_millis, right.phase.rank())) + }); + if facts + .first() + .is_none_or(|fact| fact.phase != SccmSoftwareUpdatePointPhase::Configure) + || !fact_chain_is_valid(&facts) + { + return None; + } + + let mut last_successful_phase = None; + let mut terminal_success = false; + let mut terminal_failure = false; + let mut deferred = false; + let observations = facts + .iter() + .enumerate() + .map(|(index, fact)| { + if fact.disposition == SccmSoftwareUpdatePointDisposition::Succeeded { + last_successful_phase = Some(fact.phase); + terminal_success |= fact.terminal; + } else if fact.disposition == SccmSoftwareUpdatePointDisposition::Failed { + terminal_failure |= fact.terminal; + } else { + deferred = true; + } + SccmSoftwareUpdatePointObservation { + observation_id: format!( + "{}-{:02}-{}", + key.sync_run_id, + index + 1, + fact.phase.observation_suffix(fact.disposition) + ), + phase: fact.phase, + disposition: fact.disposition, + terminal: fact.terminal, + timestamp: SccmTimestamp { + original_display: None, + offset_minutes: Some(0), + utc_millis: Some(fact.utc_millis), + ordering_state: SccmTimeOrderingState::NormalizedUtc, + }, + evidence: vec![fact.evidence.clone()], + } + }) + .collect::>(); + + let subject_artifacts = scoped_artifacts + .iter() + .copied() + .filter(|artifact| artifact.workflow_subject_handle.as_deref() == Some(&key.sup_handle)) + .collect::>(); + let gap_artifacts = subject_artifacts + .iter() + .copied() + .filter(|artifact| { + artifact.state != SccmCoverageState::Captured + || artifact.fragment_complete == Some(false) + }) + .collect::>(); + let mut coverage_gap_artifact_ids = gap_artifacts + .iter() + .map(|artifact| artifact.artifact_id.clone()) + .collect::>(); + coverage_gap_artifact_ids.sort(); + coverage_gap_artifact_ids.dedup(); + let required_gap = gap_artifacts + .iter() + .any(|artifact| artifact.source_id == SCCM_SOFTWARE_UPDATE_POINT_SYNC_SOURCE_ID); + // The profile-defined supplement is sealed by intake but has no reviewed + // semantic extractor yet. Its mere presence, including a captured payload, + // therefore keeps otherwise conclusive output at medium confidence. + let optional_supplement_uninterpreted = subject_artifacts + .iter() + .any(|artifact| artifact.source_id == SCCM_SOFTWARE_UPDATE_POINT_WSUS_SOURCE_ID); + + let (state, classification, confidence) = if required_gap { + ( + SccmSoftwareUpdatePointState::Incomplete, + SccmSoftwareUpdatePointClassification::InsufficientEvidence, + SccmSoftwareUpdatePointConfidence::Low, + ) + } else if terminal_success && !terminal_failure { + ( + SccmSoftwareUpdatePointState::Succeeded, + SccmSoftwareUpdatePointClassification::Success, + if optional_supplement_uninterpreted { + SccmSoftwareUpdatePointConfidence::Medium + } else { + SccmSoftwareUpdatePointConfidence::High + }, + ) + } else if terminal_failure && !terminal_success { + ( + SccmSoftwareUpdatePointState::Failed, + SccmSoftwareUpdatePointClassification::ConfirmedFailure, + if optional_supplement_uninterpreted { + SccmSoftwareUpdatePointConfidence::Medium + } else { + SccmSoftwareUpdatePointConfidence::High + }, + ) + } else if deferred && !terminal_failure && !terminal_success { + ( + SccmSoftwareUpdatePointState::Deferred, + SccmSoftwareUpdatePointClassification::BlockedOrDeferred, + SccmSoftwareUpdatePointConfidence::Medium, + ) + } else { + ( + SccmSoftwareUpdatePointState::Incomplete, + SccmSoftwareUpdatePointClassification::InsufficientEvidence, + SccmSoftwareUpdatePointConfidence::Low, + ) + }; + let next_source_id = (state == SccmSoftwareUpdatePointState::Incomplete) + .then(|| { + gap_artifacts + .iter() + .filter(|artifact| artifact.source_id == SCCM_SOFTWARE_UPDATE_POINT_SYNC_SOURCE_ID) + .map(|artifact| artifact.source_id.as_str()) + .min() + .map(str::to_owned) + }) + .flatten(); + let transaction_id = match &key.update_id { + Some(update_id) => format!( + "sup:{}:{}:{}:{}", + key.sync_run_id, key.site_code, key.sup_handle, update_id + ), + None => format!( + "sup:{}:{}:{}", + key.sync_run_id, key.site_code, key.sup_handle + ), + }; + + Some(SccmSoftwareUpdatePointTransaction { + transaction_id, + key: SccmSoftwareUpdatePointKey { + sync_run_id: key.sync_run_id, + site_code: key.site_code, + sup_handle: key.sup_handle, + update_id: key.update_id, + kb_id: key.kb_id, + confidence: SccmSoftwareUpdatePointKeyConfidence::Exact, + extraction_profile_id: key.profile_id, + }, + topology_compatibility: SccmSoftwareUpdatePointTopologyCompatibility::Exact, + correlation_eligible: true, + state, + classification, + confidence, + confidence_ceiling: confidence, + last_successful_phase, + next_source_id, + coverage_gap_artifact_ids, + observations, + }) +} + +fn fact_chain_is_valid(facts: &[Fact]) -> bool { + let mut previous_phase = None; + let mut previous_utc = None; + let mut terminal_seen = false; + let mut evidence = BTreeSet::new(); + for fact in facts { + if terminal_seen + || previous_phase.is_some_and(|phase| fact.phase.rank() <= phase) + || previous_utc.is_some_and(|utc| fact.utc_millis <= utc) + || !evidence.insert(( + fact.evidence.artifact_id.as_str(), + fact.evidence.start_line, + fact.evidence.end_line, + )) + { + return false; + } + if let Some(previous) = previous_phase { + if fact.phase.rank() != previous + 1 { + return false; + } + } + previous_phase = Some(fact.phase.rank()); + previous_utc = Some(fact.utc_millis); + terminal_seen = fact.terminal; + } + true +} + +fn source_local_observations( + scoped_artifacts: &[&SccmServerArtifactAssessment], +) -> Vec { + let mut observations = Vec::new(); + let mut ordinal = 1usize; + let mut split_groups = BTreeMap::<(String, String, String, String), Vec>::new(); + for artifact in scoped_artifacts.iter().filter(|artifact| { + artifact.producer_role != SccmRole::Client + && artifact.state == SccmCoverageState::Captured + && artifact.fragment_complete == Some(false) + }) { + split_groups + .entry(( + artifact.producer_host_handle.clone().unwrap_or_default(), + artifact.workflow_subject_handle.clone().unwrap_or_default(), + artifact.source_id.clone(), + artifact.rotation_lineage_handle.clone(), + )) + .or_default() + .push(artifact.artifact_id.clone()); + } + for mut artifact_ids in split_groups.into_values().filter(|ids| ids.len() > 1) { + artifact_ids.sort(); + let prefix = artifact_prefix(&artifact_ids[0]); + observations.push(source_local_observation( + format!("{prefix}-{ordinal:02}-split"), + SccmSoftwareUpdatePointSourceLocalClassification::RotationSplit, + artifact_ids, + Vec::new(), + )); + ordinal += 1; + } + + let mut malformed_artifacts = scoped_artifacts + .iter() + .filter(|artifact| { + artifact.producer_role != SccmRole::Client + && artifact.state == SccmCoverageState::ParseFailed + }) + .copied() + .collect::>(); + malformed_artifacts.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + for artifact in malformed_artifacts { + let prefix = artifact_prefix(&artifact.artifact_id); + observations.push(source_local_observation( + format!("{prefix}-{ordinal:02}-malformed"), + SccmSoftwareUpdatePointSourceLocalClassification::MalformedEvidence, + vec![artifact.artifact_id.clone()], + Vec::new(), + )); + ordinal += 1; + } + + observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + observations +} + +fn artifact_prefix(artifact_id: &str) -> &str { + artifact_id.split('-').next().unwrap_or("source") +} + +fn source_local_observation( + observation_id: String, + classification: SccmSoftwareUpdatePointSourceLocalClassification, + artifact_ids: Vec, + evidence: Vec, +) -> SccmSoftwareUpdatePointSourceLocalObservation { + SccmSoftwareUpdatePointSourceLocalObservation { + observation_id, + classification, + confidence: SccmSoftwareUpdatePointConfidence::Low, + confidence_ceiling: SccmSoftwareUpdatePointConfidence::Low, + correlation_eligible: false, + artifact_ids, + evidence, + } +} + +fn artifact_requests( + gap_artifacts: &[&SccmServerArtifactAssessment], +) -> Vec { + let mut requests = BTreeSet::new(); + for artifact in gap_artifacts { + if artifact.source_id != SCCM_SOFTWARE_UPDATE_POINT_SYNC_SOURCE_ID { + continue; + } + let state_reason = match artifact.state { + SccmCoverageState::Absent => Some(SccmSoftwareUpdatePointRequestReason::CoverageAbsent), + SccmCoverageState::AccessDenied => { + Some(SccmSoftwareUpdatePointRequestReason::CoverageAccessDenied) + } + SccmCoverageState::Capped => Some(SccmSoftwareUpdatePointRequestReason::CoverageCapped), + SccmCoverageState::ParseFailed => { + Some(SccmSoftwareUpdatePointRequestReason::CoverageMalformed) + } + SccmCoverageState::Captured + | SccmCoverageState::Skipped + | SccmCoverageState::Unsupported => None, + }; + if let Some(reason_code) = state_reason { + let Some(sup_handle) = artifact.workflow_subject_handle.clone() else { + continue; + }; + requests.insert(SccmSoftwareUpdatePointArtifactRequest { + sup_handle, + source_id: artifact.source_id.clone(), + reason_code, + }); + } + if artifact.fragment_complete == Some(false) { + let Some(sup_handle) = artifact.workflow_subject_handle.clone() else { + continue; + }; + requests.insert(SccmSoftwareUpdatePointArtifactRequest { + sup_handle, + source_id: artifact.source_id.clone(), + reason_code: SccmSoftwareUpdatePointRequestReason::CoverageRotationSplit, + }); + } + } + requests.into_iter().collect() +} diff --git a/crates/cmtraceopen-parser/src/sccm/signals.rs b/crates/cmtraceopen-parser/src/sccm/signals.rs new file mode 100644 index 000000000..ae6732818 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/signals.rs @@ -0,0 +1,135 @@ +use std::sync::OnceLock; + +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::error_db::lookup::lookup_error_code; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSignalKind { + HResult, + Gle, + ExitCode, + ReturnCode, + Status, +} + +/// A structured diagnostic token captured from an SCCM evidence message. +/// +/// `start` and `end` are an end-exclusive range measured in UTF-16 code units, +/// matching JavaScript string indexes rather than UTF-8 byte offsets. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSignal { + pub kind: SccmSignalKind, + pub raw: String, + pub numeric: Option, + pub start: usize, + pub end: usize, + pub error_description: Option, + pub error_category: Option, +} + +struct SignalPattern { + kind: SccmSignalKind, + regex: Regex, +} + +fn signal_patterns() -> &'static [SignalPattern] { + static CELL: OnceLock> = OnceLock::new(); + CELL.get_or_init(|| { + [ + ( + SccmSignalKind::HResult, + r"(?i:\bhr)=(?P0[xX][0-9A-Fa-f]{8})\b", + ), + ( + SccmSignalKind::HResult, + r"(?i:\bHRESULT)[ \t]+(?P0[xX][0-9A-Fa-f]{8})\b", + ), + ( + SccmSignalKind::Gle, + r"(?i:\[gle)=(?P0[xX][0-9A-Fa-f]{8})\]", + ), + ( + SccmSignalKind::ExitCode, + r"(?i:\bexit[ \t]+code)[ \t]+(?P[0-9]+)\b", + ), + ( + SccmSignalKind::ExitCode, + r"(?i:\bexitcode)[ \t]*=[ \t]*(?P[0-9]+)\b", + ), + ( + SccmSignalKind::ReturnCode, + r"(?i:\breturn[ \t]+code)[ \t]+(?P[0-9]+)\b", + ), + (SccmSignalKind::Status, r"(?i:\bstatus)=(?P[0-9]+)\b"), + ] + .into_iter() + .map(|(kind, pattern)| SignalPattern { + kind, + regex: Regex::new(pattern).expect("SCCM signal regex must compile"), + }) + .collect() + }) +} + +/// Extract structured SCCM diagnostic tokens in message order. +/// +/// Capture does not depend on the embedded error database: unknown or +/// out-of-range values are retained with absent numeric/enrichment fields. +pub fn extract_signals(message: &str) -> Vec { + let mut signals = signal_patterns() + .iter() + .flat_map(|pattern| { + pattern.regex.captures_iter(message).filter_map(|captures| { + let value = captures.name("value")?; + let raw = value.as_str(); + let numeric = parse_numeric(raw); + let (error_description, error_category) = numeric + .map(|numeric| lookup_error_code(&format!("0x{numeric:08X}"))) + .filter(|result| result.found) + .map(|result| (Some(result.description), Some(result.category))) + .unwrap_or((None, None)); + let start = message[..value.start()].encode_utf16().count(); + let end = start + raw.encode_utf16().count(); + + Some(SccmSignal { + kind: pattern.kind.clone(), + raw: raw.to_owned(), + numeric, + start, + end, + error_description, + error_category, + }) + }) + }) + .collect::>(); + + signals.sort_by_key(|signal| (signal.start, signal_kind_order(&signal.kind), signal.end)); + signals.dedup_by(|right, left| { + right.kind == left.kind + && right.raw == left.raw + && right.start == left.start + && right.end == left.end + }); + signals +} + +fn parse_numeric(raw: &str) -> Option { + raw.strip_prefix("0x") + .or_else(|| raw.strip_prefix("0X")) + .map_or_else(|| raw.parse().ok(), |hex| u32::from_str_radix(hex, 16).ok()) +} + +fn signal_kind_order(kind: &SccmSignalKind) -> u8 { + match kind { + SccmSignalKind::HResult => 0, + SccmSignalKind::Gle => 1, + SccmSignalKind::ExitCode => 2, + SccmSignalKind::ReturnCode => 3, + SccmSignalKind::Status => 4, + } +} diff --git a/crates/cmtraceopen-parser/tests/esp_diagnostics.rs b/crates/cmtraceopen-parser/tests/esp_diagnostics.rs index 829a41154..27262e26b 100644 --- a/crates/cmtraceopen-parser/tests/esp_diagnostics.rs +++ b/crates/cmtraceopen-parser/tests/esp_diagnostics.rs @@ -5960,10 +5960,10 @@ fn reducer_preserves_elevation_when_the_system_record_is_evicted() { let snapshot = reducer.snapshot(); // The elevation record no longer survives in the retained (raw) evidence... - assert!(!snapshot - .raw_evidence - .iter() - .any(|record| record.evidence.first().is_some_and(|e| e.evidence_id == "elevation"))); + assert!(!snapshot.raw_evidence.iter().any(|record| record + .evidence + .first() + .is_some_and(|e| e.evidence_id == "elevation"))); // ...but the reduced elevation is still authoritative and fully preserved. assert!(snapshot.elevation.is_elevated); assert!(snapshot.elevation.restart_supported); @@ -8178,9 +8178,21 @@ fn reducer_review_multiple_office_groups_backpatch_activity_statuses_independent r"registry:HKLM\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking"; const OFFICE_ROOT: &str = r"registry:HKLM\SOFTWARE\Microsoft\OfficeCSP"; let groups = [ - ("11111111-1111-1111-1111-111111111111", 70, EspNormalizedStatus::Succeeded), - ("22222222-2222-2222-2222-222222222222", 60, EspNormalizedStatus::Failed), - ("33333333-3333-3333-3333-333333333333", 40, EspNormalizedStatus::Downloaded), + ( + "11111111-1111-1111-1111-111111111111", + 70, + EspNormalizedStatus::Succeeded, + ), + ( + "22222222-2222-2222-2222-222222222222", + 60, + EspNormalizedStatus::Failed, + ), + ( + "33333333-3333-3333-3333-333333333333", + 40, + EspNormalizedStatus::Downloaded, + ), ]; let mut reducer = EspDiagnosticsReducer::new("2026-07-15T18:00:00Z".to_string()); let mut records = Vec::new(); @@ -10506,7 +10518,10 @@ fn redaction_projection_masks_azure_sas_and_account_key_credentials() { // Credential values are redacted everywhere they appear. assert!(!safe_json.contains("Zx9AbCdEf0"), "SAS sig leaked"); assert!(!safe_json.contains("abcDEF123"), "AccountKey leaked"); - assert!(!safe_json.contains("AbC%3D"), "SharedAccessSignature sig leaked"); + assert!( + !safe_json.contains("AbC%3D"), + "SharedAccessSignature sig leaked" + ); // The credential-bearing raw record failed closed. assert!(safe.raw_evidence.is_empty()); // Non-secret URL context survives in the narrative message. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md new file mode 100644 index 000000000..88a331bce --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md @@ -0,0 +1,66 @@ +# Synthetic SCCM client intake fixtures + +These are compiled pure-intake fixtures for issue #319. The parser test harness +maps their declared artifact fields into the published #318 spine types and +checks the executable #319 assessment. `manifest.json` remains a +`proposalOnly` native wire design: no native SCCM manifest reader, discovery, +capture, legacy adapter, or Windows acceptance is implied. Every identity, +path, timestamp, byte count, UUID, and log record is deterministic and +synthetic. + +Privacy markers: manifests require `syntheticFixture: true` and +`proposalOnly: true`; issue #319 intake evidence uses only `LAB-CLIENT-01`, the +exact synthetic three-character site code `LAB`, fake package/content IDs, or +RFC-style test UUIDs; `SYNTHETIC://` is opaque fixture provenance. Workflow +corpora own their issue-scoped identifier contracts. Never replace these files +with a copied client log. No user, SID, tenant, certificate, token, serial, +production deployment name, customer host, or real source path may be +committed. + +`complete` covers all first-pass catalog groups and represents +`LocationServices.log` once with stable `client-content` and `client-location` +memberships. `rotations` proves declared current/`.lo_`/numbered grouping. +`collision` proves two current `AppEnforce.log` files from distinct roots keep +unique physical IDs, fingerprints, contents, and collision-safe relative +paths; `root-a` and `root-b` are opaque configured-root handles, not native +paths. +`missing-root`, `access-denied`, and `capped` prove coverage behavior only; +their evidence must not form workflow findings. `skipped`, `unsafe-path`, and +legacy generic-manifest mapping are intentionally documented native test +designs in `docs/sccm/preparation/issue-319-client-intake.md` and remain +pending. + +`contractState` is `pureIntakeImplementedNativePending`. Each `expected.json` +separates three contracts: `pureAssessment` is a typed, exact normalized view +of every public group, fragment, physical artifact, unsupported artifact, and +coverage gap; `nativeDesignPending` retains bounded byte/digest expectations +without claiming a native reader or collector exists; and +`downstreamDesignPending` labels request wording and prohibited diagnostic +claims that are not intake output. The pure arrays retain their deterministic +production order, while the deduplicated fragment table and pending native +artifact provenance are stable-sorted by artifact ID. Mutation tests reject +unknown fields, omissions, reordered output, and forged provenance. No test +interprets capped/split fragment text as a phase or terminal diagnosis. + +Every manifest artifact has one physical `artifactId` and a +`designOnlyCatalog` object containing one catalog entry plus sorted logical +group memberships. These are preparation labels, not final #318 field names. +For every `captured` or `capped` artifact, `bytesCopied` equals the physical +evidence-file length, `encoding` is `utf-8`, and `collectionLimit` states both +the byte limit and whether it applied; `expected.json` mirrors those values in +`nativeDesignPending.artifactProvenance`, including an exact `bytesCopied` for +every physical fixture. In `manifest.json`, noncapture artifacts remain +`bytesCopied: 0` with a null relative path; they are omitted from +`expected.json`'s `nativeDesignPending.artifactProvenance` and do not invent +capture provenance. An applied cap counts +inclusive raw source bytes before decoding and retains that exact source +prefix, even when the last byte splits a text or logical-record boundary. The +collector never appends a truncation marker or repairs/replaces bytes. The +capped evidence is exactly 128 bytes, is explicitly truncated and +fragment-incomplete, retains the pre-existing synthetic marker inside those +bytes, and is not a complete CCM record. Expected data locks its exact byte +count and SHA-256. + +The first line of every evidence file must contain the literal +`SYNTHETIC FIXTURE` plus scenario-specific coverage text; CCM files put it +inside the first record and the plain supplemental fixture uses it directly. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..7d0753101 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-content/current/CAS.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-content/current/CAS.log new file mode 100644 index 000000000..f7e59b13a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-content/current/CAS.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..63b3eed24 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-content/current/DataTransferService.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/expected.json new file mode 100644 index 000000000..f4b4bd922 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/expected.json @@ -0,0 +1,26 @@ +{ + "contractState":"proposedPending318And319", + "workflow":"deployment", + "scenario":"bits-transfer-failure", + "stateChain":["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract":{"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile":{"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","bitsJobId","ciId","contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"validatedArtifactFamilies":["client-app-intent","client-content"]}, + "reorderedInputDeterministic":true, + "coverage":[{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"captured"}], + "artifactProvenance":[ + {"artifactId":"deployment-bits-transfer-failure-content-current","bytesCopied":452,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-bits-transfer-failure-intent-current","bytesCopied":532,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-bits-transfer-failure-transfer-current","bytesCopied":743,"encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions":[{ + "transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000007", + "key":{"keyProfileKind":"assignmentCiContentTopology","assignmentId":"10000000-0000-0000-0000-000000000007","ciId":"20000000-0000-0000-0000-000000001007","packageId":"LAB00007","contentId":"30000000-0000-0000-0000-000000002007","contentVersion":7,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003007","bitsJobId":"50000000-0000-0000-0000-000000004007","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact":{"factKind":"clientContentRequest","phase":"locateContent","extractionProfileId":"deployment-client-5.00.test-v1","packageId":"LAB00007","contentId":"30000000-0000-0000-0000-000000002007","contentVersion":7,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003007","timestampProvenance":{"kind":"explicitOffset","offsetMinutes":0,"normalizedUtc":"2026-07-30T04:00:02Z"},"evidence":{"artifactId":"deployment-bits-transfer-failure-content-current","startLine":1,"endLine":1}}, + "phase":"transfer","state":"failed","lastSuccessfulPhase":"locateContent","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","coverageGapArtifactIds":[],"nextArtifact":null, + "evidence":[{"artifactId":"deployment-bits-transfer-failure-content-current","startLine":1,"endLine":1},{"artifactId":"deployment-bits-transfer-failure-intent-current","startLine":1,"endLine":2},{"artifactId":"deployment-bits-transfer-failure-transfer-current","startLine":1,"endLine":2}] + }], + "sourceLocalObservations":[], + "findings":[], + "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims":["distribution point or server root cause","network root cause from a client transfer error","time-only content-to-DP correlation"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/manifest.json new file mode 100644 index 000000000..1f83a7bc8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "bits-transfer-failure", + "bundle": {"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"deployment-bits-transfer-failure-content-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CAS.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CAS.log","pathFingerprint":"synthetic:deployment-bits-transfer-failure-content","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:00:10Z","bytesCopied":452,"relativePath":"evidence/client-content/current/CAS.log"}, + {"artifactId":"deployment-bits-transfer-failure-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-bits-transfer-failure-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:00:10Z","bytesCopied":532,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"}, + {"artifactId":"deployment-bits-transfer-failure-transfer-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"DataTransferService.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/DataTransferService.log","pathFingerprint":"synthetic:deployment-bits-transfer-failure-transfer","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:00:10Z","bytesCopied":743,"relativePath":"evidence/client-content/current/DataTransferService.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..377805b4f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-content/current/CAS.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-content/current/CAS.log new file mode 100644 index 000000000..7969005fc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-content/current/CAS.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..4dad8c22c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-content/current/DataTransferService.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/expected.json new file mode 100644 index 000000000..2dd5e89ad --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/expected.json @@ -0,0 +1,26 @@ +{ + "contractState":"proposedPending318And319", + "workflow":"deployment", + "scenario":"cache-failure", + "stateChain":["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract":{"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile":{"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","bitsJobId","ciId","contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"validatedArtifactFamilies":["client-app-intent","client-content"]}, + "reorderedInputDeterministic":true, + "coverage":[{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"captured"}], + "artifactProvenance":[ + {"artifactId":"deployment-cache-failure-content-current","bytesCopied":799,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-cache-failure-intent-current","bytesCopied":532,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-cache-failure-transfer-current","bytesCopied":701,"encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions":[{ + "transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000008", + "key":{"keyProfileKind":"assignmentCiContentTopology","assignmentId":"10000000-0000-0000-0000-000000000008","ciId":"20000000-0000-0000-0000-000000001008","packageId":"LAB00008","contentId":"30000000-0000-0000-0000-000000002008","contentVersion":8,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003008","bitsJobId":"50000000-0000-0000-0000-000000004008","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact":{"factKind":"clientContentRequest","phase":"locateContent","extractionProfileId":"deployment-client-5.00.test-v1","packageId":"LAB00008","contentId":"30000000-0000-0000-0000-000000002008","contentVersion":8,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003008","timestampProvenance":{"kind":"explicitOffset","offsetMinutes":0,"normalizedUtc":"2026-07-30T04:10:02Z"},"evidence":{"artifactId":"deployment-cache-failure-content-current","startLine":1,"endLine":1}}, + "phase":"cache","state":"failed","lastSuccessfulPhase":"transfer","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","coverageGapArtifactIds":[],"nextArtifact":null, + "evidence":[{"artifactId":"deployment-cache-failure-content-current","startLine":1,"endLine":2},{"artifactId":"deployment-cache-failure-intent-current","startLine":1,"endLine":2},{"artifactId":"deployment-cache-failure-transfer-current","startLine":1,"endLine":2}] + }], + "sourceLocalObservations":[], + "findings":[], + "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims":["distribution point or server root cause","cache failure proves transfer failure","time-only content-to-DP correlation"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/manifest.json new file mode 100644 index 000000000..f81215926 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"cache-failure", + "bundle":{"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts":[ + {"artifactId":"deployment-cache-failure-content-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CAS.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CAS.log","pathFingerprint":"synthetic:deployment-cache-failure-content","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:10:10Z","bytesCopied":799,"relativePath":"evidence/client-content/current/CAS.log"}, + {"artifactId":"deployment-cache-failure-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-cache-failure-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:10:10Z","bytesCopied":532,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"}, + {"artifactId":"deployment-cache-failure-transfer-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"DataTransferService.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/DataTransferService.log","pathFingerprint":"synthetic:deployment-cache-failure-transfer","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:10:10Z","bytesCopied":701,"relativePath":"evidence/client-content/current/DataTransferService.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..d4a799713 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/expected.json new file mode 100644 index 000000000..5cc35921b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/expected.json @@ -0,0 +1,29 @@ +{ + "contractState":"proposedPending318And319", + "workflow":"deployment", + "scenario":"dependency-failure", + "stateChain":["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract":{"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile":{"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","bitsJobId","ciId","contentId","contentVersion","distributionPointHostHandle","exitCode","packageId","productCode","requestId"],"validatedArtifactFamilies":["client-app-enforce","client-app-intent","client-content","client-policy-state"]}, + "reorderedInputDeterministic":true, + "coverage":[{"logicalArtifactId":"client-app-intent","state":"captured"}], + "artifactProvenance":[{"artifactId":"deployment-dependency-failure-intent-current","bytesCopied":859,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}], + "transactions":[{ + "transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000004", + "key":{"keyProfileKind":"assignmentCi","assignmentId":"10000000-0000-0000-0000-000000000004","ciId":"20000000-0000-0000-0000-000000001004","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact":null, + "phase":"requirements", + "state":"failed", + "lastSuccessfulPhase":"intent", + "classification":"confirmedFailure", + "confidence":"high", + "confidenceCeiling":"high", + "coverageGapArtifactIds":[], + "nextArtifact":null, + "evidence":[{"artifactId":"deployment-dependency-failure-intent-current","startLine":1,"endLine":3}] + }], + "sourceLocalObservations":[], + "findings":[{"findingId":"deployment-dependency-terminal","class":"confirmedFailure","phase":"requirements","role":"client","confidence":"high","evidence":[{"artifactId":"deployment-dependency-failure-intent-current","startLine":3,"endLine":3}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["download failure","distribution point cause","policy output required"]}], + "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":false}, + "prohibitedClaims":["distribution point or server root cause","time-only content-to-DP correlation","policy output required for deployment behavior"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/manifest.json new file mode 100644 index 000000000..041ce2f96 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"dependency-failure", + "bundle":{"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts":[ + {"artifactId":"deployment-dependency-failure-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-dependency-failure-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:30:10Z","bytesCopied":859,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-enforce/current/AppEnforce.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-enforce/current/AppEnforce.log new file mode 100644 index 000000000..0724828b0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-enforce/current/AppEnforce.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-intent/current/AppDiscovery.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-intent/current/AppDiscovery.log new file mode 100644 index 000000000..8f098b24a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-intent/current/AppDiscovery.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..be7884bd0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-content/current/CAS.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-content/current/CAS.log new file mode 100644 index 000000000..a657c4925 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-content/current/CAS.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..92fd03d68 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-content/current/DataTransferService.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/expected.json new file mode 100644 index 000000000..0f24b8cd6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/expected.json @@ -0,0 +1,28 @@ +{ + "contractState":"proposedPending318And319", + "workflow":"deployment", + "scenario":"detection-false-negative", + "stateChain":["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract":{"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile":{"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","bitsJobId","ciId","contentId","contentVersion","distributionPointHostHandle","exitCode","packageId","productCode","requestId"],"validatedArtifactFamilies":["client-app-enforce","client-app-intent","client-content"]}, + "reorderedInputDeterministic":true, + "coverage":[{"logicalArtifactId":"client-app-enforce","state":"captured"},{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"captured"}], + "artifactProvenance":[ + {"artifactId":"deployment-detection-false-negative-content-current","bytesCopied":759,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-detection-false-negative-detect-current","bytesCopied":345,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-detection-false-negative-enforce-current","bytesCopied":350,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-detection-false-negative-intent-current","bytesCopied":532,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-detection-false-negative-transfer-current","bytesCopied":702,"encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions":[{ + "transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000010", + "key":{"keyProfileKind":"assignmentCiContentTopology","assignmentId":"10000000-0000-0000-0000-000000000010","ciId":"20000000-0000-0000-0000-000000001010","packageId":"LAB00010","contentId":"30000000-0000-0000-0000-000000002010","contentVersion":10,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003010","bitsJobId":"50000000-0000-0000-0000-000000004010","productCode":"60000000-0000-0000-0000-000000005010","exitCode":"0","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact":{"factKind":"clientContentRequest","phase":"locateContent","extractionProfileId":"deployment-client-5.00.test-v1","packageId":"LAB00010","contentId":"30000000-0000-0000-0000-000000002010","contentVersion":10,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003010","timestampProvenance":{"kind":"explicitOffset","offsetMinutes":0,"normalizedUtc":"2026-07-30T04:30:02Z"},"evidence":{"artifactId":"deployment-detection-false-negative-content-current","startLine":1,"endLine":1}}, + "phase":"detect","state":"detectionMismatch","lastSuccessfulPhase":"enforce","classification":"symptom","confidence":"medium","confidenceCeiling":"medium","coverageGapArtifactIds":[],"nextArtifact":null, + "evidence":[{"artifactId":"deployment-detection-false-negative-content-current","startLine":1,"endLine":2},{"artifactId":"deployment-detection-false-negative-detect-current","startLine":1,"endLine":1},{"artifactId":"deployment-detection-false-negative-enforce-current","startLine":1,"endLine":1},{"artifactId":"deployment-detection-false-negative-intent-current","startLine":1,"endLine":2},{"artifactId":"deployment-detection-false-negative-transfer-current","startLine":1,"endLine":2}] + }], + "sourceLocalObservations":[], + "findings":[], + "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims":["installer failed","distribution point or server root cause","detection mismatch proves content failure"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/manifest.json new file mode 100644 index 000000000..80d982707 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/manifest.json @@ -0,0 +1,14 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"detection-false-negative", + "bundle":{"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts":[ + {"artifactId":"deployment-detection-false-negative-content-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CAS.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CAS.log","pathFingerprint":"synthetic:deployment-detection-false-negative-content","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:30:10Z","bytesCopied":759,"relativePath":"evidence/client-content/current/CAS.log"}, + {"artifactId":"deployment-detection-false-negative-detect-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppDiscovery.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppDiscovery.log","pathFingerprint":"synthetic:deployment-detection-false-negative-detect","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:30:10Z","bytesCopied":345,"relativePath":"evidence/client-app-intent/current/AppDiscovery.log"}, + {"artifactId":"deployment-detection-false-negative-enforce-current","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log","pathFingerprint":"synthetic:deployment-detection-false-negative-enforce","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:30:10Z","bytesCopied":350,"relativePath":"evidence/client-app-enforce/current/AppEnforce.log"}, + {"artifactId":"deployment-detection-false-negative-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-detection-false-negative-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:30:10Z","bytesCopied":532,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"}, + {"artifactId":"deployment-detection-false-negative-transfer-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"DataTransferService.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/DataTransferService.log","pathFingerprint":"synthetic:deployment-detection-false-negative-transfer","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:30:10Z","bytesCopied":702,"relativePath":"evidence/client-content/current/DataTransferService.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..98eeb23ff --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/evidence/client-content/current/CAS.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/evidence/client-content/current/CAS.log new file mode 100644 index 000000000..45fb266c1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/evidence/client-content/current/CAS.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/expected.json new file mode 100644 index 000000000..623821c8c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/expected.json @@ -0,0 +1,35 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "deployment", + "scenario": "dp-content-missing", + "stateChain": ["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract": {"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","ciId","contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"validatedArtifactFamilies":["client-app-intent","client-content"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"deployment-dp-content-missing-content-current","bytesCopied":483,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-dp-content-missing-intent-current","bytesCopied":532,"encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [{ + "transactionId": "deployment:assignment:10000000-0000-0000-0000-000000000006", + "key": {"keyProfileKind":"assignmentCiContentTopology","assignmentId":"10000000-0000-0000-0000-000000000006","ciId":"20000000-0000-0000-0000-000000001006","packageId":"LAB00006","contentId":"30000000-0000-0000-0000-000000002006","contentVersion":6,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003006","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact": {"factKind":"clientContentRequest","phase":"locateContent","extractionProfileId":"deployment-client-5.00.test-v1","packageId":"LAB00006","contentId":"30000000-0000-0000-0000-000000002006","contentVersion":6,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003006","timestampProvenance":{"kind":"explicitOffset","offsetMinutes":0,"normalizedUtc":"2026-07-30T03:50:02Z"},"evidence":{"artifactId":"deployment-dp-content-missing-content-current","startLine":1,"endLine":1}}, + "phase": "locateContent", + "state": "insufficientEvidence", + "lastSuccessfulPhase": "requirements", + "classification": "symptom", + "confidence": "low", + "confidenceCeiling": "low", + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-content","reason":"capture a complete terminal location response; a client request alone cannot prove DP content state"}, + "evidence": [ + {"artifactId":"deployment-dp-content-missing-content-current","startLine":1,"endLine":1}, + {"artifactId":"deployment-dp-content-missing-intent-current","startLine":1,"endLine":2} + ] + }], + "sourceLocalObservations": [], + "findings": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["distribution point lacks content","distribution point or server root cause","time-only content-to-DP correlation"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/manifest.json new file mode 100644 index 000000000..fe5d925b6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "dp-content-missing", + "bundle": {"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"deployment-dp-content-missing-content-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CAS.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CAS.log","pathFingerprint":"synthetic:deployment-dp-content-missing-content","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:50:10Z","bytesCopied":483,"relativePath":"evidence/client-content/current/CAS.log"}, + {"artifactId":"deployment-dp-content-missing-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-dp-content-missing-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:50:10Z","bytesCopied":532,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-app-enforce/current/AppEnforce.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-app-enforce/current/AppEnforce.log new file mode 100644 index 000000000..fc54d8a01 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-app-enforce/current/AppEnforce.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..cb5a136f4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-content/current/CAS.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-content/current/CAS.log new file mode 100644 index 000000000..f7e3c9443 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-content/current/CAS.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..d79b95384 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-content/current/DataTransferService.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-installer-supplemental/current/InstallerSupplemental.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-installer-supplemental/current/InstallerSupplemental.log new file mode 100644 index 000000000..79f665599 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-installer-supplemental/current/InstallerSupplemental.log @@ -0,0 +1 @@ +SYNTHETIC FIXTURE supplemental installer text at 2026-07-30T04:20:06Z; no validated assignment, CI, product, or content key. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/expected.json new file mode 100644 index 000000000..b9b666b8c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/expected.json @@ -0,0 +1,28 @@ +{ + "contractState":"proposedPending318And319", + "workflow":"deployment", + "scenario":"enforcement-exit", + "stateChain":["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract":{"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile":{"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","bitsJobId","ciId","contentId","contentVersion","distributionPointHostHandle","exitCode","packageId","productCode","requestId"],"validatedArtifactFamilies":["client-app-enforce","client-app-intent","client-content"]}, + "reorderedInputDeterministic":true, + "coverage":[{"logicalArtifactId":"client-app-enforce","state":"captured"},{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"captured"}], + "artifactProvenance":[ + {"artifactId":"deployment-enforcement-exit-content-current","bytesCopied":757,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-enforcement-exit-enforce-current","bytesCopied":360,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-enforcement-exit-intent-current","bytesCopied":532,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-enforcement-exit-supplemental-current","bytesCopied":125,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-enforcement-exit-transfer-current","bytesCopied":701,"encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions":[{ + "transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000009", + "key":{"keyProfileKind":"assignmentCiContentTopology","assignmentId":"10000000-0000-0000-0000-000000000009","ciId":"20000000-0000-0000-0000-000000001009","packageId":"LAB00009","contentId":"30000000-0000-0000-0000-000000002009","contentVersion":9,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003009","bitsJobId":"50000000-0000-0000-0000-000000004009","productCode":"60000000-0000-0000-0000-000000005009","exitCode":"1603","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact":{"factKind":"clientContentRequest","phase":"locateContent","extractionProfileId":"deployment-client-5.00.test-v1","packageId":"LAB00009","contentId":"30000000-0000-0000-0000-000000002009","contentVersion":9,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003009","timestampProvenance":{"kind":"explicitOffset","offsetMinutes":0,"normalizedUtc":"2026-07-30T04:20:02Z"},"evidence":{"artifactId":"deployment-enforcement-exit-content-current","startLine":1,"endLine":1}}, + "phase":"enforce","state":"failed","lastSuccessfulPhase":"cache","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","coverageGapArtifactIds":[],"nextArtifact":null, + "evidence":[{"artifactId":"deployment-enforcement-exit-content-current","startLine":1,"endLine":2},{"artifactId":"deployment-enforcement-exit-enforce-current","startLine":1,"endLine":1},{"artifactId":"deployment-enforcement-exit-intent-current","startLine":1,"endLine":2},{"artifactId":"deployment-enforcement-exit-transfer-current","startLine":1,"endLine":2}] + }], + "sourceLocalObservations":[], + "findings":[], + "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims":["supplemental installer text overrides exact CCM evidence","distribution point or server root cause","time-only content-to-DP correlation"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/manifest.json new file mode 100644 index 000000000..a1687a591 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/manifest.json @@ -0,0 +1,14 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"enforcement-exit", + "bundle":{"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts":[ + {"artifactId":"deployment-enforcement-exit-content-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CAS.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CAS.log","pathFingerprint":"synthetic:deployment-enforcement-exit-content","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:20:10Z","bytesCopied":757,"relativePath":"evidence/client-content/current/CAS.log"}, + {"artifactId":"deployment-enforcement-exit-enforce-current","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log","pathFingerprint":"synthetic:deployment-enforcement-exit-enforce","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:20:10Z","bytesCopied":360,"relativePath":"evidence/client-app-enforce/current/AppEnforce.log"}, + {"artifactId":"deployment-enforcement-exit-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-enforcement-exit-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:20:10Z","bytesCopied":532,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"}, + {"artifactId":"deployment-enforcement-exit-supplemental-current","designOnlyCatalog":{"entryId":"client-installer-supplemental","groupMemberships":["client-installer-supplemental"]},"role":"client","kind":"supplementalLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"InstallerSupplemental.log","sanitizedSourcePath":"SYNTHETIC://root-a/Supplemental/InstallerSupplemental.log","pathFingerprint":"synthetic:deployment-enforcement-exit-supplemental","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":null,"capturedUtc":"2026-07-30T04:20:10Z","bytesCopied":125,"relativePath":"evidence/client-installer-supplemental/current/InstallerSupplemental.log"}, + {"artifactId":"deployment-enforcement-exit-transfer-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"DataTransferService.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/DataTransferService.log","pathFingerprint":"synthetic:deployment-enforcement-exit-transfer","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:20:10Z","bytesCopied":701,"relativePath":"evidence/client-content/current/DataTransferService.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/evidence/client-app-enforce/current/AppEnforce.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/evidence/client-app-enforce/current/AppEnforce.log new file mode 100644 index 000000000..5237cc54c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/evidence/client-app-enforce/current/AppEnforce.log @@ -0,0 +1 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/expected.json new file mode 100644 index 000000000..17edd8e9b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/expected.json @@ -0,0 +1,23 @@ +{ + "contractState":"proposedPending318And319", + "workflow":"deployment", + "scenario":"incomplete", + "stateChain":["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract":{"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile":{"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","ciId"],"validatedArtifactFamilies":["client-app-intent"]}, + "reorderedInputDeterministic":true, + "coverage":[{"logicalArtifactId":"client-app-enforce","state":"capped"},{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"accessDenied"}], + "artifactProvenance":[ + {"artifactId":"deployment-incomplete-enforce-capped","bytesCopied":146,"encoding":"utf-8","byteLimit":146,"limitApplied":true}, + {"artifactId":"deployment-incomplete-intent-current","bytesCopied":590,"encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions":[ + {"transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000012","key":{"keyProfileKind":"assignmentCi","assignmentId":"10000000-0000-0000-0000-000000000012","ciId":"20000000-0000-0000-0000-000000001012","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"},"counterpartReadyFact":null,"phase":"locateContent","state":"insufficientEvidence","lastSuccessfulPhase":"requirements","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low","coverageGapArtifactIds":["deployment-incomplete-content-access-denied"],"nextArtifact":{"logicalArtifactId":"client-content","reason":"access denied is a coverage state, not proof of content success or failure"},"evidence":[{"artifactId":"deployment-incomplete-intent-current","startLine":1,"endLine":1}]}, + {"transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000013","key":{"keyProfileKind":"assignmentCi","assignmentId":"10000000-0000-0000-0000-000000000013","ciId":"20000000-0000-0000-0000-000000001013","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"},"counterpartReadyFact":null,"phase":"locateContent","state":"insufficientEvidence","lastSuccessfulPhase":"requirements","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low","coverageGapArtifactIds":["deployment-incomplete-content-access-denied"],"nextArtifact":{"logicalArtifactId":"client-content","reason":"access denied is a coverage state, not proof of content success or failure"},"evidence":[{"artifactId":"deployment-incomplete-intent-current","startLine":2,"endLine":2}]} + ], + "sourceLocalObservations":[], + "findings":[], + "adversarialControls":{"sameMinuteDifferentExactKeysStaySeparate":true,"cappedUnkeyedFragmentStaysSourceLocal":true,"accessDeniedIsCoverageOnly":true}, + "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":false}, + "prohibitedClaims":["same-minute transactions merge","access denied proves content success or failure","capped enforcement text proves an outcome"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/manifest.json new file mode 100644 index 000000000..722977eca --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"incomplete", + "bundle":{"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts":[ + {"artifactId":"deployment-incomplete-content-access-denied","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"accessDenied","encoding":null,"collectionLimit":null,"originalBasename":"CAS.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic:deployment-incomplete-content-access-denied","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:50:10Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"deployment-incomplete-enforce-capped","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"capped","encoding":"utf-8","collectionLimit":{"byteLimit":146,"limitApplied":true},"originalBasename":"AppEnforce.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log","pathFingerprint":"synthetic:deployment-incomplete-enforce","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:50:10Z","bytesCopied":146,"relativePath":"evidence/client-app-enforce/current/AppEnforce.log"}, + {"artifactId":"deployment-incomplete-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-incomplete-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:50:10Z","bytesCopied":590,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..19abf3133 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/expected.json new file mode 100644 index 000000000..ae4ff3bbe --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/expected.json @@ -0,0 +1,29 @@ +{ + "contractState":"proposedPending318And319", + "workflow":"deployment", + "scenario":"location-missing", + "stateChain":["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract":{"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile":{"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","bitsJobId","ciId","contentId","contentVersion","distributionPointHostHandle","exitCode","packageId","productCode","requestId"],"validatedArtifactFamilies":["client-app-enforce","client-app-intent","client-content","client-policy-state"]}, + "reorderedInputDeterministic":true, + "coverage":[{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"absent"}], + "artifactProvenance":[{"artifactId":"deployment-location-missing-intent-current","bytesCopied":532,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}], + "transactions":[{ + "transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000005", + "key":{"keyProfileKind":"assignmentCi","assignmentId":"10000000-0000-0000-0000-000000000005","ciId":"20000000-0000-0000-0000-000000001005","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact":null, + "phase":"locateContent", + "state":"insufficientEvidence", + "lastSuccessfulPhase":"requirements", + "classification":"insufficientEvidence", + "confidence":"low", + "confidenceCeiling":"low", + "coverageGapArtifactIds":["deployment-location-missing-content-absent"], + "nextArtifact":{"logicalArtifactId":"client-content","reason":"Capture an exact client content-location response for this assignment and CI."}, + "evidence":[{"artifactId":"deployment-location-missing-intent-current","startLine":1,"endLine":2}] + }], + "sourceLocalObservations":[], + "findings":[{"findingId":"deployment-location-coverage-gap","class":"insufficientEvidence","phase":"locateContent","role":"client","confidence":"low","evidence":[{"artifactId":"deployment-location-missing-intent-current","startLine":1,"endLine":2}],"coverageGapArtifactIds":["deployment-location-missing-content-absent"],"nextArtifacts":[{"logicalArtifactId":"client-content","reason":"Capture an exact client content-location response for this assignment and CI."}],"mustNotClaim":["client is not targeted","distribution point unavailable","server cause"]}], + "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":false}, + "prohibitedClaims":["distribution point or server root cause","time-only content-to-DP correlation","policy output required for deployment behavior"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/manifest.json new file mode 100644 index 000000000..b35648408 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"location-missing", + "bundle":{"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts":[ + {"artifactId":"deployment-location-missing-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-location-missing-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:40:10Z","bytesCopied":532,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"}, + {"artifactId":"deployment-location-missing-content-absent","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"CAS.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic:deployment-location-missing-content-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:40:10Z","bytesCopied":0,"relativePath":null} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..9f9abef7b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/expected.json new file mode 100644 index 000000000..7b37c408c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/expected.json @@ -0,0 +1,29 @@ +{ + "contractState":"proposedPending318And319", + "workflow":"deployment", + "scenario":"not-targeted", + "stateChain":["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract":{"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile":{"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","bitsJobId","ciId","contentId","contentVersion","distributionPointHostHandle","exitCode","packageId","productCode","requestId"],"validatedArtifactFamilies":["client-app-enforce","client-app-intent","client-content","client-policy-state"]}, + "reorderedInputDeterministic":true, + "coverage":[{"logicalArtifactId":"client-app-intent","state":"captured"}], + "artifactProvenance":[{"artifactId":"deployment-not-targeted-intent-current","bytesCopied":315,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}], + "transactions":[{ + "transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000002", + "key":{"keyProfileKind":"assignmentCi","assignmentId":"10000000-0000-0000-0000-000000000002","ciId":"20000000-0000-0000-0000-000000001002","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact":null, + "phase":"intent", + "state":"notTargeted", + "lastSuccessfulPhase":null, + "classification":"notTargeted", + "confidence":"high", + "confidenceCeiling":"high", + "coverageGapArtifactIds":[], + "nextArtifact":null, + "evidence":[{"artifactId":"deployment-not-targeted-intent-current","startLine":1,"endLine":1}] + }], + "sourceLocalObservations":[], + "findings":[], + "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":false}, + "prohibitedClaims":["distribution point or server root cause","time-only content-to-DP correlation","policy output required for deployment behavior"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/manifest.json new file mode 100644 index 000000000..2de2ccd1c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "not-targeted", + "bundle": {"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"deployment-not-targeted-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-not-targeted-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:10:10Z","bytesCopied":315,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..6c0467d6b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/expected.json new file mode 100644 index 000000000..4301adb76 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/expected.json @@ -0,0 +1,29 @@ +{ + "contractState":"proposedPending318And319", + "workflow":"deployment", + "scenario":"requirements-failure", + "stateChain":["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract":{"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile":{"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","bitsJobId","ciId","contentId","contentVersion","distributionPointHostHandle","exitCode","packageId","productCode","requestId"],"validatedArtifactFamilies":["client-app-enforce","client-app-intent","client-content","client-policy-state"]}, + "reorderedInputDeterministic":true, + "coverage":[{"logicalArtifactId":"client-app-intent","state":"captured"}], + "artifactProvenance":[{"artifactId":"deployment-requirements-failure-intent-current","bytesCopied":580,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}], + "transactions":[{ + "transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000003", + "key":{"keyProfileKind":"assignmentCi","assignmentId":"10000000-0000-0000-0000-000000000003","ciId":"20000000-0000-0000-0000-000000001003","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact":null, + "phase":"requirements", + "state":"failed", + "lastSuccessfulPhase":"intent", + "classification":"confirmedFailure", + "confidence":"high", + "confidenceCeiling":"high", + "coverageGapArtifactIds":[], + "nextArtifact":null, + "evidence":[{"artifactId":"deployment-requirements-failure-intent-current","startLine":1,"endLine":2}] + }], + "sourceLocalObservations":[], + "findings":[{"findingId":"deployment-requirements-terminal","class":"confirmedFailure","phase":"requirements","role":"client","confidence":"high","evidence":[{"artifactId":"deployment-requirements-failure-intent-current","startLine":2,"endLine":2}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["download failure","distribution point cause","policy output required"]}], + "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":false}, + "prohibitedClaims":["distribution point or server root cause","time-only content-to-DP correlation","policy output required for deployment behavior"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/manifest.json new file mode 100644 index 000000000..68f1d3a4c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"requirements-failure", + "bundle":{"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts":[ + {"artifactId":"deployment-requirements-failure-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-requirements-failure-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:20:10Z","bytesCopied":580,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..7eb87f16e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-content/current/CAS.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-content/current/CAS.log new file mode 100644 index 000000000..df794d3e9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-content/current/CAS.log @@ -0,0 +1 @@ +0000-0000-000000002011 contentVersion=11 requestId=40000000-0000-0000-0000-000000003011]LOG]!> diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-content/lo/CAS.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-content/lo/CAS.lo_ new file mode 100644 index 000000000..b326fb186 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-content/lo/CAS.lo_ @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-app-intent/current/AppDiscovery.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-app-intent/current/AppDiscovery.log new file mode 100644 index 000000000..a8bac3775 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-app-intent/current/AppDiscovery.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..c75ba2317 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-content/current/CAS.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-content/current/CAS.log new file mode 100644 index 000000000..ada387fee --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-content/current/CAS.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..60fbec312 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-content/current/DataTransferService.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-policy-state/current/StateMessage.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-policy-state/current/StateMessage.log new file mode 100644 index 000000000..292c1ef47 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-policy-state/current/StateMessage.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/expected.json new file mode 100644 index 000000000..9ee413faf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/expected.json @@ -0,0 +1,43 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "deployment", + "scenario": "success", + "stateChain": ["intent","requirements","locateContent","transfer","cache","enforce","detect","report"], + "analysisContract": {"independentReducer":true,"consumesPolicyReducerOutput":false,"policyCoverageRequired":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"deployment-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","contentVersionRequired":true,"keyKinds":["assignmentId","bitsJobId","ciId","contentId","contentVersion","distributionPointHostHandle","exitCode","packageId","productCode","requestId"],"validatedArtifactFamilies":["client-app-enforce","client-app-intent","client-content","client-policy-state"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-app-enforce","state":"captured"},{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"captured"},{"logicalArtifactId":"client-policy-agent","state":"absent"},{"logicalArtifactId":"client-policy-state","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"deployment-success-content-current","bytesCopied":765,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-success-detect-current","bytesCopied":336,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-success-enforce-current","bytesCopied":358,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-success-intent-current","bytesCopied":538,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-success-report-current","bytesCopied":289,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"deployment-success-transfer-current","bytesCopied":709,"encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [{ + "transactionId": "deployment:assignment:10000000-0000-0000-0000-000000000001", + "key": {"keyProfileKind":"assignmentCiContentTopology","assignmentId":"10000000-0000-0000-0000-000000000001","ciId":"20000000-0000-0000-0000-000000001001","packageId":"LAB00001","contentId":"30000000-0000-0000-0000-000000002001","contentVersion":3,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003001","bitsJobId":"50000000-0000-0000-0000-000000004001","productCode":"60000000-0000-0000-0000-000000005001","exitCode":"0","confidence":"exact","extractionProfileId":"deployment-client-5.00.test-v1"}, + "counterpartReadyFact": {"factKind":"clientContentRequest","phase":"locateContent","extractionProfileId":"deployment-client-5.00.test-v1","packageId":"LAB00001","contentId":"30000000-0000-0000-0000-000000002001","contentVersion":3,"distributionPointHostHandle":"safe:dp:lab-dp-01","requestId":"40000000-0000-0000-0000-000000003001","timestampProvenance":{"kind":"explicitOffset","offsetMinutes":0,"normalizedUtc":"2026-07-30T03:00:02Z"},"evidence":{"artifactId":"deployment-success-content-current","startLine":1,"endLine":1}}, + "phase": "report", + "state": "succeeded", + "lastSuccessfulPhase": "report", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"deployment-success-content-current","startLine":1,"endLine":2}, + {"artifactId":"deployment-success-detect-current","startLine":1,"endLine":1}, + {"artifactId":"deployment-success-enforce-current","startLine":1,"endLine":1}, + {"artifactId":"deployment-success-intent-current","startLine":1,"endLine":2}, + {"artifactId":"deployment-success-report-current","startLine":1,"endLine":1}, + {"artifactId":"deployment-success-transfer-current","startLine":1,"endLine":2} + ] + }], + "sourceLocalObservations": [], + "findings": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["distribution point or server root cause","time-only content-to-DP correlation","policy output required for deployment behavior"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/manifest.json new file mode 100644 index 000000000..8c95a454e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/manifest.json @@ -0,0 +1,16 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "success", + "bundle": {"role":"client","workflow":"deployment","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"deployment-success-enforce-current","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log","pathFingerprint":"synthetic:deployment-success-enforce","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:00:10Z","bytesCopied":358,"relativePath":"evidence/client-app-enforce/current/AppEnforce.log"}, + {"artifactId":"deployment-success-detect-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppDiscovery.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppDiscovery.log","pathFingerprint":"synthetic:deployment-success-detect","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:00:10Z","bytesCopied":336,"relativePath":"evidence/client-app-intent/current/AppDiscovery.log"}, + {"artifactId":"deployment-success-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic:deployment-success-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:00:10Z","bytesCopied":538,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"}, + {"artifactId":"deployment-success-content-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CAS.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CAS.log","pathFingerprint":"synthetic:deployment-success-content","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:00:10Z","bytesCopied":765,"relativePath":"evidence/client-content/current/CAS.log"}, + {"artifactId":"deployment-success-transfer-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"DataTransferService.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/DataTransferService.log","pathFingerprint":"synthetic:deployment-success-transfer","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:00:10Z","bytesCopied":709,"relativePath":"evidence/client-content/current/DataTransferService.log"}, + {"artifactId":"deployment-success-policy-absent","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"PolicyAgent.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic:deployment-success-policy-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:00:10Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"deployment-success-report-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"StateMessage.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/StateMessage.log","pathFingerprint":"synthetic:deployment-success-report","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:00:10Z","bytesCopied":289,"relativePath":"evidence/client-policy-state/current/StateMessage.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/cross-client-management-point.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/cross-client-management-point.json new file mode 100644 index 000000000..29a353f1e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/cross-client-management-point.json @@ -0,0 +1,207 @@ +{ + "findings": [ + { + "class": "symptom", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "findingId": "client-health-managementPointLocation-stop", + "healthPhase": "managementPointLocation", + "lastConfirmedSuccessfulPhase": "boundary", + "nextArtifacts": [ + { + "logicalId": "locationServices", + "reason": "Confirm the root cause recorded by LocationServices.", + "role": "client" + } + ], + "phase": "managementPointLocation", + "role": "client", + "severity": "Warning", + "summary": "The admitted management point location source does not contain one unambiguous terminal outcome for the exact chain key.", + "terminalEvidence": [], + "title": "Client health management point location outcome is not confirmed" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-success-ccmsetup-current", + "entryId": "health-success-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "service", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "clientHealth", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "reboot", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-identity-current", + "entryId": "health-success-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "identity", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-identity-current", + "entryId": "health-success-identity-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "authentication", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "assignment", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "boundary", + "state": "succeeded" + } + ], + "lastConfirmedSuccessfulPhase": "boundary", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-success-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-location-services-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-location", + "physical": true, + "rotation": { + "kind": "current" + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/repair-retry-after-install-success.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/repair-retry-after-install-success.json new file mode 100644 index 000000000..2f76a3a78 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/repair-retry-after-install-success.json @@ -0,0 +1,99 @@ +{ + "findings": [ + { + "class": "symptom", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-success-ccmsetup-current", + "entryId": "health-success-ccmsetup-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "findingId": "client-health-repair-stop", + "healthPhase": "repair", + "lastConfirmedSuccessfulPhase": null, + "nextArtifacts": [ + { + "logicalId": "ccmSetup", + "reason": "Confirm the root cause recorded by ccmsetup.", + "role": "client" + } + ], + "phase": "repair", + "role": "client", + "severity": "Warning", + "summary": "The admitted repair source does not contain one unambiguous terminal outcome for the exact chain key.", + "terminalEvidence": [], + "title": "Client health repair outcome is not confirmed" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-success-ccmsetup-current", + "entryId": "health-success-ccmsetup-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "repair", + "state": "pending" + } + ], + "lastConfirmedSuccessfulPhase": null, + "lifecyclePhase": "repair", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-success-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-location-services-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-location", + "physical": true, + "rotation": { + "kind": "current" + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/repair-retry-terminal-after-pending.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/repair-retry-terminal-after-pending.json new file mode 100644 index 000000000..41c03acd8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/repair-retry-terminal-after-pending.json @@ -0,0 +1,176 @@ +{ + "findings": [], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-success-ccmsetup-current", + "entryId": "health-success-ccmsetup-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "repair", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "service", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "clientHealth", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "reboot", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-identity-current", + "entryId": "health-success-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "identity", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-identity-current", + "entryId": "health-success-identity-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "authentication", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "assignment", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "boundary", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "managementPointLocation", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "phase": "transport", + "state": "succeeded" + } + ], + "lastConfirmedSuccessfulPhase": "transport", + "lifecyclePhase": "repair", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-success-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-location-services-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-location", + "physical": true, + "rotation": { + "kind": "current" + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/service-before-install.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/service-before-install.json new file mode 100644 index 000000000..79511f67c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/service-before-install.json @@ -0,0 +1,111 @@ +{ + "findings": [ + { + "class": "symptom", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "findingId": "client-health-service-stop", + "healthPhase": "service", + "lastConfirmedSuccessfulPhase": "install", + "nextArtifacts": [ + { + "logicalId": "ccmEval", + "reason": "Confirm the root cause recorded by CcmEval.", + "role": "client" + } + ], + "phase": "service", + "role": "client", + "severity": "Warning", + "summary": "The admitted service source does not contain one unambiguous terminal outcome for the exact chain key.", + "terminalEvidence": [], + "title": "Client health service outcome is not confirmed" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-success-ccmsetup-current", + "entryId": "health-success-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + } + ], + "lastConfirmedSuccessfulPhase": "install", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-success-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-location-services-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-location", + "physical": true, + "rotation": { + "kind": "current" + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/service-retry-after-success.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/service-retry-after-success.json new file mode 100644 index 000000000..91b797dff --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/service-retry-after-success.json @@ -0,0 +1,111 @@ +{ + "findings": [ + { + "class": "symptom", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "findingId": "client-health-service-stop", + "healthPhase": "service", + "lastConfirmedSuccessfulPhase": "install", + "nextArtifacts": [ + { + "logicalId": "ccmEval", + "reason": "Confirm the root cause recorded by CcmEval.", + "role": "client" + } + ], + "phase": "service", + "role": "client", + "severity": "Warning", + "summary": "The admitted service source does not contain one unambiguous terminal outcome for the exact chain key.", + "terminalEvidence": [], + "title": "Client health service outcome is not confirmed" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-success-ccmsetup-current", + "entryId": "health-success-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "phase": "service", + "state": "pending" + } + ], + "lastConfirmedSuccessfulPhase": "install", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-success-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-location-services-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-location", + "physical": true, + "rotation": { + "kind": "current" + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..4ee46b82b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-evaluation/current/CcmEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-evaluation/current/CcmEval.log new file mode 100644 index 000000000..da743a476 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-identity/current/ClientIDManagerStartup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-identity/current/ClientIDManagerStartup.log new file mode 100644 index 000000000..3f498f0da --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/expected.json new file mode 100644 index 000000000..9719cee73 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/expected.json @@ -0,0 +1,173 @@ +{ + "productionAdmissionError": null, + "productionOutput": { + "findings": [ + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-authentication-failure-identity-current", + "entryId": "health-authentication-failure-identity-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "findingId": "client-health-authentication-stop", + "healthPhase": "authentication", + "lastConfirmedSuccessfulPhase": "identity", + "nextArtifacts": [ + { + "logicalId": "clientIdManagerStartup", + "reason": "Confirm the root cause recorded by ClientIDManagerStartup.", + "role": "client" + } + ], + "phase": "authentication", + "role": "client", + "severity": "Error", + "summary": "Admitted client evidence recorded a terminal failure at the authentication phase.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "health-authentication-failure-identity-current", + "entryId": "health-authentication-failure-identity-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + } + ], + "title": "Client health authentication recorded a terminal failure" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-authentication-failure-ccmsetup-current", + "entryId": "health-authentication-failure-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-authentication-failure-evaluation-current", + "entryId": "health-authentication-failure-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "service", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-authentication-failure-evaluation-current", + "entryId": "health-authentication-failure-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "clientHealth", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-authentication-failure-evaluation-current", + "entryId": "health-authentication-failure-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "reboot", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-authentication-failure-identity-current", + "entryId": "health-authentication-failure-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "identity", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-authentication-failure-identity-current", + "entryId": "health-authentication-failure-identity-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "authentication", + "state": "failed" + } + ], + "lastConfirmedSuccessfulPhase": "identity", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-authentication-failure-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-authentication-failure-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-authentication-failure-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-authentication-failure-location-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-location", + "physical": false, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "authentication-failure" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/manifest.json new file mode 100644 index 000000000..ce5887acb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "scenario": "authentication-failure", + "syntheticFixture": true, + "artifacts": [ + {"artifactId":"health-authentication-failure-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-success-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T03:00:00Z","bytesCopied":260,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-authentication-failure-evaluation-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"CcmEval.log","pathFingerprint":"synthetic-root-a-health-success-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T03:00:01Z","bytesCopied":742,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-authentication-failure-identity-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-root-a-health-success-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T03:00:04Z","bytesCopied":564,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-authentication-failure-location-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"LocationServices.log","pathFingerprint":"synthetic-candidate-health-success-location-services-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T03:00:06Z","bytesCopied":0,"relativePath":null} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..094c467a9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-evaluation/current/CcmEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-evaluation/current/CcmEval.log new file mode 100644 index 000000000..445171480 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-identity/current/ClientIDManagerStartup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-identity/current/ClientIDManagerStartup.log new file mode 100644 index 000000000..e325eeb62 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..a532809f8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/expected.json new file mode 100644 index 000000000..66dea9e5b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/expected.json @@ -0,0 +1,197 @@ +{ + "productionAdmissionError": null, + "productionOutput": { + "findings": [ + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-boundary-location-failure-location-current", + "entryId": "health-boundary-location-failure-location-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "findingId": "client-health-boundary-stop", + "healthPhase": "boundary", + "lastConfirmedSuccessfulPhase": "assignment", + "nextArtifacts": [ + { + "logicalId": "locationServices", + "reason": "Confirm the root cause recorded by LocationServices.", + "role": "client" + } + ], + "phase": "boundary", + "role": "client", + "severity": "Error", + "summary": "Admitted client evidence recorded a terminal failure at the boundary phase.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "health-boundary-location-failure-location-current", + "entryId": "health-boundary-location-failure-location-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + } + ], + "title": "Client health boundary recorded a terminal failure" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-boundary-location-failure-ccmsetup-current", + "entryId": "health-boundary-location-failure-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-boundary-location-failure-evaluation-current", + "entryId": "health-boundary-location-failure-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "service", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-boundary-location-failure-evaluation-current", + "entryId": "health-boundary-location-failure-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "clientHealth", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-boundary-location-failure-evaluation-current", + "entryId": "health-boundary-location-failure-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "reboot", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-boundary-location-failure-identity-current", + "entryId": "health-boundary-location-failure-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "identity", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-boundary-location-failure-identity-current", + "entryId": "health-boundary-location-failure-identity-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "authentication", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-boundary-location-failure-location-current", + "entryId": "health-boundary-location-failure-location-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "assignment", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-boundary-location-failure-location-current", + "entryId": "health-boundary-location-failure-location-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "boundary", + "state": "failed" + } + ], + "lastConfirmedSuccessfulPhase": "assignment", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-boundary-location-failure-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-boundary-location-failure-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-boundary-location-failure-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-boundary-location-failure-location-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-location", + "physical": true, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "boundary-location-failure" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/manifest.json new file mode 100644 index 000000000..f9d4e26bf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "scenario": "boundary-location-failure", + "syntheticFixture": true, + "artifacts": [ + {"artifactId":"health-boundary-location-failure-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-success-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T03:10:00Z","bytesCopied":260,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-boundary-location-failure-evaluation-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"CcmEval.log","pathFingerprint":"synthetic-root-a-health-success-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T03:10:01Z","bytesCopied":742,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-boundary-location-failure-identity-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-root-a-health-success-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T03:10:04Z","bytesCopied":550,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-boundary-location-failure-location-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"LocationServices.log","pathFingerprint":"synthetic-root-a-health-success-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T03:10:06Z","bytesCopied":562,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..28732291f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/expected.json new file mode 100644 index 000000000..0d1200f04 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/expected.json @@ -0,0 +1,115 @@ +{ + "productionAdmissionError": null, + "productionOutput": { + "findings": [ + { + "class": "symptom", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-contradictory-ccmsetup-current", + "entryId": "health-contradictory-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "health-contradictory-ccmsetup-current", + "entryId": "health-contradictory-ccmsetup-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "findingId": "client-health-install-stop", + "healthPhase": "install", + "lastConfirmedSuccessfulPhase": null, + "nextArtifacts": [ + { + "logicalId": "ccmSetup", + "reason": "Confirm the root cause recorded by ccmsetup.", + "role": "client" + } + ], + "phase": "install", + "role": "client", + "severity": "Warning", + "summary": "The admitted install source does not contain one unambiguous terminal outcome for the exact chain key.", + "terminalEvidence": [], + "title": "Client health install outcome is not confirmed" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-contradictory-ccmsetup-current", + "entryId": "health-contradictory-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "health-contradictory-ccmsetup-current", + "entryId": "health-contradictory-ccmsetup-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "install", + "state": "contradictory" + } + ], + "lastConfirmedSuccessfulPhase": null, + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-contradictory-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-contradictory-evaluation-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-evaluation", + "physical": false, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-contradictory-identity-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-identity", + "physical": false, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-contradictory-location-services-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-location", + "physical": false, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "contradictory" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/manifest.json new file mode 100644 index 000000000..472de1ea1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "scenario": "contradictory", + "artifacts": [ + {"artifactId":"health-contradictory-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-contradictory-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:50:02Z","bytesCopied":516,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-contradictory-evaluation-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"CcmEval.log","pathFingerprint":"synthetic-candidate-health-contradictory-evaluation-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:50:01Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-contradictory-identity-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-candidate-health-contradictory-identity-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:50:02Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-contradictory-location-services-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"LocationServices.log","pathFingerprint":"synthetic-candidate-health-contradictory-location-services-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:50:03Z","bytesCopied":0,"relativePath":null} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..ac649938f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-evaluation/current/CcmEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-evaluation/current/CcmEval.log new file mode 100644 index 000000000..4e0bdfb29 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-identity/current/ClientIDManagerStartup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-identity/current/ClientIDManagerStartup.log new file mode 100644 index 000000000..2befb51e9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/expected.json new file mode 100644 index 000000000..e59080815 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/expected.json @@ -0,0 +1,161 @@ +{ + "productionAdmissionError": null, + "productionOutput": { + "findings": [ + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-identity-failure-identity-current", + "entryId": "health-identity-failure-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "client-health-identityRegistration-stop", + "healthPhase": "identity", + "lastConfirmedSuccessfulPhase": "reboot", + "nextArtifacts": [ + { + "logicalId": "clientIdManagerStartup", + "reason": "Confirm the root cause recorded by ClientIDManagerStartup.", + "role": "client" + } + ], + "phase": "identityRegistration", + "role": "client", + "severity": "Error", + "summary": "Admitted client evidence recorded a terminal failure at the identity registration phase.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "health-identity-failure-identity-current", + "entryId": "health-identity-failure-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + } + ], + "title": "Client health identity registration recorded a terminal failure" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-identity-failure-ccmsetup-current", + "entryId": "health-identity-failure-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-identity-failure-evaluation-current", + "entryId": "health-identity-failure-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "service", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-identity-failure-evaluation-current", + "entryId": "health-identity-failure-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "clientHealth", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-identity-failure-evaluation-current", + "entryId": "health-identity-failure-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "reboot", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-identity-failure-identity-current", + "entryId": "health-identity-failure-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "identity", + "state": "failed" + } + ], + "lastConfirmedSuccessfulPhase": "reboot", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-identity-failure-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-identity-failure-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-identity-failure-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-identity-failure-location-services-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-location", + "physical": false, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "identity-failure" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/manifest.json new file mode 100644 index 000000000..61cd8e7e6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "scenario": "identity-failure", + "artifacts": [ + {"artifactId":"health-identity-failure-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-identity-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:20:00Z","bytesCopied":260,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-identity-failure-evaluation-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"CcmEval.log","pathFingerprint":"synthetic-root-a-health-identity-failure-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:20:01Z","bytesCopied":742,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-identity-failure-identity-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-root-a-health-identity-failure-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:20:02Z","bytesCopied":302,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-identity-failure-location-services-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"LocationServices.log","pathFingerprint":"synthetic-candidate-health-identity-failure-location-services-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:20:03Z","bytesCopied":0,"relativePath":null} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..11bd172d0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/evidence/client-evaluation/current/CcmEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/evidence/client-evaluation/current/CcmEval.log new file mode 100644 index 000000000..59edabda2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/expected.json new file mode 100644 index 000000000..a9a2f53cb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/expected.json @@ -0,0 +1,138 @@ +{ + "productionAdmissionError": null, + "productionOutput": { + "findings": [ + { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "health-incomplete-identity-access-denied", + "coverage": "accessDenied", + "role": "client" + } + ], + "evidence": [], + "findingId": "client-health-identityRegistration-stop", + "healthPhase": "identity", + "lastConfirmedSuccessfulPhase": "reboot", + "nextArtifacts": [ + { + "logicalId": "clientIdManagerStartup", + "reason": "Confirm the root cause recorded by ClientIDManagerStartup.", + "role": "client" + } + ], + "phase": "identityRegistration", + "role": "client", + "severity": "Warning", + "summary": "The identity registration phase cannot be evaluated because its exact source coverage is incomplete.", + "terminalEvidence": [], + "title": "Client health identity registration evidence is incomplete" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-incomplete-ccmsetup-current", + "entryId": "health-incomplete-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-incomplete-evaluation-current", + "entryId": "health-incomplete-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "service", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-incomplete-evaluation-current", + "entryId": "health-incomplete-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "clientHealth", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-incomplete-evaluation-current", + "entryId": "health-incomplete-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "reboot", + "state": "succeeded" + } + ], + "lastConfirmedSuccessfulPhase": "reboot", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-incomplete-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-incomplete-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-incomplete-identity-access-denied", + "coverage": "accessDenied", + "fragmentComplete": false, + "logicalArtifactId": "client-identity", + "physical": false, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-incomplete-location-services-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-location", + "physical": false, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "incomplete" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/manifest.json new file mode 100644 index 000000000..6d9f364aa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "scenario": "incomplete", + "artifacts": [ + {"artifactId":"health-incomplete-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-incomplete-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T02:20:00Z","bytesCopied":260,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-incomplete-evaluation-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"CcmEval.log","pathFingerprint":"synthetic-root-a-health-incomplete-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T02:20:01Z","bytesCopied":742,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-incomplete-identity-access-denied","role":"client","captureState":"accessDenied","encoding":null,"originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-candidate-health-incomplete-identity-access-denied","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T02:20:02Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-incomplete-location-services-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"LocationServices.log","pathFingerprint":"synthetic-candidate-health-incomplete-location-services-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T02:20:03Z","bytesCopied":0,"relativePath":null} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..09b1d981c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/evidence/client-evaluation/current/CcmEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/evidence/client-evaluation/current/CcmEval.log new file mode 100644 index 000000000..c6ffe8fd9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-evaluation/current/CcmEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-evaluation/current/CcmEval.log new file mode 100644 index 000000000..8ba998507 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-identity/current/ClientIDManagerStartup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-identity/current/ClientIDManagerStartup.log new file mode 100644 index 000000000..475b821a1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..c761bbfaa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/expected.json new file mode 100644 index 000000000..a06b15fff --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/expected.json @@ -0,0 +1,175 @@ +{ + "productionAdmissionError": null, + "productionOutput": { + "findings": [ + { + "class": "symptom", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-no-site-or-mp-location-services-current", + "entryId": "health-no-site-or-mp-location-services-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "client-health-assignment-stop", + "healthPhase": "assignment", + "lastConfirmedSuccessfulPhase": "authentication", + "nextArtifacts": [ + { + "logicalId": "locationServices", + "reason": "Confirm the root cause recorded by LocationServices.", + "role": "client" + } + ], + "phase": "assignment", + "role": "client", + "severity": "Warning", + "summary": "The admitted assignment source does not contain one unambiguous terminal outcome for the exact chain key.", + "terminalEvidence": [], + "title": "Client health assignment outcome is not confirmed" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-no-site-or-mp-ccmsetup-current", + "entryId": "health-no-site-or-mp-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-no-site-or-mp-evaluation-current", + "entryId": "health-no-site-or-mp-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "service", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-no-site-or-mp-evaluation-current", + "entryId": "health-no-site-or-mp-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "clientHealth", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-no-site-or-mp-evaluation-current", + "entryId": "health-no-site-or-mp-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "reboot", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-no-site-or-mp-identity-current", + "entryId": "health-no-site-or-mp-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "identity", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-no-site-or-mp-identity-current", + "entryId": "health-no-site-or-mp-identity-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "authentication", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-no-site-or-mp-location-services-current", + "entryId": "health-no-site-or-mp-location-services-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "assignment", + "state": "pending" + } + ], + "lastConfirmedSuccessfulPhase": "authentication", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-no-site-or-mp-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-no-site-or-mp-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-no-site-or-mp-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-no-site-or-mp-location-services-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-location", + "physical": true, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "no-site-or-mp" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/manifest.json new file mode 100644 index 000000000..3cc7da99c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "scenario": "no-site-or-mp", + "artifacts": [ + {"artifactId":"health-no-site-or-mp-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-no-site-or-mp-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:30:00Z","bytesCopied":260,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-no-site-or-mp-evaluation-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"CcmEval.log","pathFingerprint":"synthetic-root-a-health-no-site-or-mp-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:30:01Z","bytesCopied":742,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-no-site-or-mp-identity-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-root-a-health-no-site-or-mp-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:30:02Z","bytesCopied":550,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-no-site-or-mp-location-services-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"LocationServices.log","pathFingerprint":"synthetic-root-a-health-no-site-or-mp-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:30:04Z","bytesCopied":650,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..0b336f577 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1 @@ +]LOG]!> diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/ccmsetup.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/ccmsetup.lo_ new file mode 100644 index 000000000..f0067020d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/ccmsetup.lo_ @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/expected.json new file mode 100644 index 000000000..1c3c2d913 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/expected.json @@ -0,0 +1,113 @@ +{ + "productionAdmissionError": null, + "productionOutput": { + "findings": [ + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-setup-failure-ccmsetup-current", + "entryId": "health-setup-failure-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "client-health-install-stop", + "healthPhase": "install", + "lastConfirmedSuccessfulPhase": null, + "nextArtifacts": [ + { + "logicalId": "ccmSetup", + "reason": "Confirm the root cause recorded by ccmsetup.", + "role": "client" + } + ], + "phase": "install", + "role": "client", + "severity": "Error", + "summary": "Admitted client evidence recorded a terminal failure at the install phase.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "health-setup-failure-ccmsetup-current", + "entryId": "health-setup-failure-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + } + ], + "title": "Client health install recorded a terminal failure" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-setup-failure-ccmsetup-current", + "entryId": "health-setup-failure-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "failed" + } + ], + "lastConfirmedSuccessfulPhase": null, + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-setup-failure-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-setup-failure-evaluation-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-evaluation", + "physical": false, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-setup-failure-identity-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-identity", + "physical": false, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-setup-failure-location-services-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-location", + "physical": false, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "setup-failure" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/manifest.json new file mode 100644 index 000000000..e202591c6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "scenario": "setup-failure", + "artifacts": [ + {"artifactId":"health-setup-failure-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-setup-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:10:00Z","bytesCopied":275,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-setup-failure-evaluation-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"CcmEval.log","pathFingerprint":"synthetic-candidate-health-setup-failure-evaluation-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:10:01Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-setup-failure-identity-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-candidate-health-setup-failure-identity-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:10:02Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-setup-failure-location-services-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"LocationServices.log","pathFingerprint":"synthetic-candidate-health-setup-failure-location-services-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:10:03Z","bytesCopied":0,"relativePath":null} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..1bbe3efa4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-evaluation/current/CcmEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-evaluation/current/CcmEval.log new file mode 100644 index 000000000..cf9eff344 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-identity/current/ClientIDManagerStartup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-identity/current/ClientIDManagerStartup.log new file mode 100644 index 000000000..e372ed143 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..ce1b1d1de --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/expected.json new file mode 100644 index 000000000..bd6f4ee96 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/expected.json @@ -0,0 +1,180 @@ +{ + "productionAdmissionError": null, + "productionOutput": { + "findings": [], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-success-ccmsetup-current", + "entryId": "health-success-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "service", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "clientHealth", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-evaluation-current", + "entryId": "health-success-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "reboot", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-identity-current", + "entryId": "health-success-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "identity", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-identity-current", + "entryId": "health-success-identity-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "authentication", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "assignment", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "boundary", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "managementPointLocation", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-success-location-services-current", + "entryId": "health-success-location-services-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "phase": "transport", + "state": "succeeded" + } + ], + "lastConfirmedSuccessfulPhase": "transport", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-success-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-success-location-services-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-location", + "physical": true, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "success" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/manifest.json new file mode 100644 index 000000000..bb8f35b46 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "scenario": "success", + "artifacts": [ + {"artifactId":"health-success-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-success-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:00:00Z","bytesCopied":260,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-success-evaluation-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"CcmEval.log","pathFingerprint":"synthetic-root-a-health-success-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:00:01Z","bytesCopied":742,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-success-identity-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-root-a-health-success-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:00:02Z","bytesCopied":550,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-success-location-services-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"LocationServices.log","pathFingerprint":"synthetic-root-a-health-success-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:00:07Z","bytesCopied":1376,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..1e612d58b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-evaluation/current/CcmEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-evaluation/current/CcmEval.log new file mode 100644 index 000000000..d13500332 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-identity/current/ClientIDManagerStartup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-identity/current/ClientIDManagerStartup.log new file mode 100644 index 000000000..f8d927c5d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..06799fea2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/expected.json new file mode 100644 index 000000000..9cb9aa1c2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/expected.json @@ -0,0 +1,221 @@ +{ + "productionAdmissionError": null, + "productionOutput": { + "findings": [ + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "health-transport-failure-location-services-current", + "entryId": "health-transport-failure-location-services-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "findingId": "client-health-transport-stop", + "healthPhase": "transport", + "lastConfirmedSuccessfulPhase": "managementPointLocation", + "nextArtifacts": [ + { + "logicalId": "locationServices", + "reason": "Confirm the root cause recorded by LocationServices.", + "role": "client" + } + ], + "phase": "transport", + "role": "client", + "severity": "Error", + "summary": "Admitted client evidence recorded a terminal failure at the transport phase.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "health-transport-failure-location-services-current", + "entryId": "health-transport-failure-location-services-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + } + ], + "title": "Client health transport recorded a terminal failure" + } + ], + "hops": [ + { + "evidence": [ + { + "artifactId": "health-transport-failure-ccmsetup-current", + "entryId": "health-transport-failure-ccmsetup-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "install", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-transport-failure-evaluation-current", + "entryId": "health-transport-failure-evaluation-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "service", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-transport-failure-evaluation-current", + "entryId": "health-transport-failure-evaluation-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "clientHealth", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-transport-failure-evaluation-current", + "entryId": "health-transport-failure-evaluation-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "reboot", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-transport-failure-identity-current", + "entryId": "health-transport-failure-identity-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "identity", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-transport-failure-identity-current", + "entryId": "health-transport-failure-identity-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "authentication", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-transport-failure-location-services-current", + "entryId": "health-transport-failure-location-services-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "phase": "assignment", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-transport-failure-location-services-current", + "entryId": "health-transport-failure-location-services-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "phase": "boundary", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-transport-failure-location-services-current", + "entryId": "health-transport-failure-location-services-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "phase": "managementPointLocation", + "state": "succeeded" + }, + { + "evidence": [ + { + "artifactId": "health-transport-failure-location-services-current", + "entryId": "health-transport-failure-location-services-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "phase": "transport", + "state": "failed" + } + ], + "lastConfirmedSuccessfulPhase": "managementPointLocation", + "lifecyclePhase": "install", + "prohibitedClaims": [ + "server root cause", + "isolated error proves terminal failure", + "missing source proves workflow failure", + "host path or raw sensitive text export" + ], + "schemaVersion": 1, + "sourceCoverage": [ + { + "artifactId": "health-transport-failure-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-transport-failure-evaluation-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-evaluation", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-transport-failure-identity-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-identity", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-transport-failure-location-services-current", + "coverage": "captured", + "fragmentComplete": true, + "logicalArtifactId": "client-location", + "physical": true, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "transport-failure" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/manifest.json new file mode 100644 index 000000000..b53bcffb3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "scenario": "transport-failure", + "artifacts": [ + {"artifactId":"health-transport-failure-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-transport-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:40:00Z","bytesCopied":260,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-transport-failure-evaluation-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"CcmEval.log","pathFingerprint":"synthetic-root-a-health-transport-failure-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:40:01Z","bytesCopied":742,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-transport-failure-identity-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-root-a-health-transport-failure-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:40:02Z","bytesCopied":550,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-transport-failure-location-services-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"LocationServices.log","pathFingerprint":"synthetic-root-a-health-transport-failure-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T01:40:07Z","bytesCopied":1390,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/evidence/client-policy-state/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/evidence/client-policy-state/current/CIAgent.log new file mode 100644 index 000000000..d6d9a17a7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/evidence/client-policy-state/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/expected.json new file mode 100644 index 000000000..0ebdb91cc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/expected.json @@ -0,0 +1,281 @@ +{ + "contractState": "pureIntakeImplementedNativePending", + "scenario": "access-denied", + "pureAssessment": { + "schemaVersion": 1, + "groups": [ + { + "logicalArtifactId": "client-app-enforce", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-app-intent", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-ccmsetup", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-compliance", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-content", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-evaluation", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-identity", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-inventory", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location-services-shared", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-maintenance-window", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-metering", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "accessDenied", + "fragmentArtifactIds": [ + "fixture-access-policy-agent-root-a-current" + ] + }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-state", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-access-policy-state-root-a-current" + ] + }, + { + "logicalArtifactId": "client-reboot", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-updates", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "coverage": "absent", + "fragmentArtifactIds": [] + } + ], + "fragments": [ + { + "artifactId": "fixture-access-policy-agent-root-a-current", + "basename": "PolicyAgent.log", + "rotation": { + "kind": "current" + }, + "coverage": "accessDenied", + "pathFingerprint": "synthetic-policy-agent-denied", + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:02:00Z", + "encoding": null + }, + { + "artifactId": "fixture-access-policy-state-root-a-current", + "basename": "CIAgent.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-policy-state", + "relativePath": "evidence/client-policy-state/current/CIAgent.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:02:01Z", + "encoding": "utf-8" + } + ], + "physicalArtifactIds": [ + "fixture-access-policy-state-root-a-current" + ], + "unsupportedArtifacts": [], + "coverageGaps": [ + { + "logicalArtifactId": "client-app-enforce", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-app-intent", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-ccmsetup", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-compliance", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-content", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-evaluation", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-identity", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-inventory", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-location", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-location-services-shared", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-maintenance-window", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-metering", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-policy-agent", + "artifactId": "fixture-access-policy-agent-root-a-current", + "role": "client", + "coverage": "accessDenied", + "reason": "Access was denied for client source PolicyAgent.log." + }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-reboot", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-updates", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + } + ] + }, + "nativeDesignPending": { + "artifactProvenance": [ + { + "artifactId": "fixture-access-policy-state-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 194 + } + ] + }, + "downstreamDesignPending": { + "workflowDiagnosisExpected": false, + "requests": [ + { + "logicalArtifactId": "client-policy-agent", + "reason": "Access must be provided for the bounded policy-agent source group." + } + ], + "prohibitedClaims": [ + "policy failed", + "management point failure" + ] + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/manifest.json new file mode 100644 index 000000000..44c7913c4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"fixture-access-policy-agent-root-a-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"accessDenied","originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic-policy-agent-denied","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:02:00Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-access-policy-state-root-a-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic-policy-state","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:02:01Z","bytesCopied":194,"relativePath":"evidence/client-policy-state/current/CIAgent.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..38873d09a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/evidence/client-content/current/DataTransferService.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/evidence/client-app-enforce/root-b/current/AppEnforce.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/evidence/client-app-enforce/root-b/current/AppEnforce.log new file mode 100644 index 000000000..41e0995b5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/evidence/client-app-enforce/root-b/current/AppEnforce.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json new file mode 100644 index 000000000..ad2b58d8d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json @@ -0,0 +1,279 @@ +{ + "contractState": "pureIntakeImplementedNativePending", + "scenario": "collision", + "pureAssessment": { + "schemaVersion": 1, + "groups": [ + { + "logicalArtifactId": "client-app-enforce", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-collision-app-enforce-root-a-current", + "fixture-collision-app-enforce-root-b-current" + ] + }, + { + "logicalArtifactId": "client-app-intent", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-ccmsetup", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-compliance", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-content", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-evaluation", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-identity", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-inventory", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location-services-shared", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-maintenance-window", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-metering", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-state", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-reboot", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-updates", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "coverage": "absent", + "fragmentArtifactIds": [] + } + ], + "fragments": [ + { + "artifactId": "fixture-collision-app-enforce-root-a-current", + "basename": "AppEnforce.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-collision-root-a", + "relativePath": "evidence/client-app-enforce/root-a/current/AppEnforce.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:05:00Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-collision-app-enforce-root-b-current", + "basename": "AppEnforce.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-collision-root-b", + "relativePath": "evidence/client-app-enforce/root-b/current/AppEnforce.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:05:01Z", + "encoding": "utf-8" + } + ], + "physicalArtifactIds": [ + "fixture-collision-app-enforce-root-a-current", + "fixture-collision-app-enforce-root-b-current" + ], + "unsupportedArtifacts": [], + "coverageGaps": [ + { + "logicalArtifactId": "client-app-intent", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-ccmsetup", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-compliance", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-content", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-evaluation", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-identity", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-inventory", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-location", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-location-services-shared", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-maintenance-window", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-metering", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-policy-agent", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-policy-state", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-reboot", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-updates", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + } + ] + }, + "nativeDesignPending": { + "artifactProvenance": [ + { + "artifactId": "fixture-collision-app-enforce-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 180 + }, + { + "artifactId": "fixture-collision-app-enforce-root-b-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 180 + } + ] + }, + "downstreamDesignPending": { + "workflowDiagnosisExpected": false, + "requests": [], + "prohibitedClaims": [] + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/manifest.json new file mode 100644 index 000000000..95e0d7d57 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/manifest.json @@ -0,0 +1,54 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "fixture-collision-app-enforce-root-a-current", + "designOnlyCatalog": { + "entryId": "client-app-enforce", + "groupMemberships": ["client-app-enforce"] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "originalBasename": "AppEnforce.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/AppEnforce.log", + "pathFingerprint": "synthetic-collision-root-a", + "rotation": {"kind": "current", "fragmentComplete": true}, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T00:05:00Z", + "bytesCopied": 180, + "relativePath": "evidence/client-app-enforce/root-a/current/AppEnforce.log" + }, + { + "artifactId": "fixture-collision-app-enforce-root-b-current", + "designOnlyCatalog": { + "entryId": "client-app-enforce", + "groupMemberships": ["client-app-enforce"] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "originalBasename": "AppEnforce.log", + "sanitizedSourcePath": "SYNTHETIC://root-b/CCM/Logs/AppEnforce.log", + "pathFingerprint": "synthetic-collision-root-b", + "rotation": {"kind": "current", "fragmentComplete": true}, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T00:05:01Z", + "bytesCopied": 180, + "relativePath": "evidence/client-app-enforce/root-b/current/AppEnforce.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-app-enforce/current/AppEnforce.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-app-enforce/current/AppEnforce.log new file mode 100644 index 000000000..631ecd7d2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-app-enforce/current/AppEnforce.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-app-intent/current/AppIntentEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-app-intent/current/AppIntentEval.log new file mode 100644 index 000000000..3b8d8e0a3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-app-intent/current/AppIntentEval.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-ccmsetup/current/ccmsetup.log new file mode 100644 index 000000000..a77ed9936 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-ccmsetup/current/ccmsetup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-compliance/current/DCMReporting.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-compliance/current/DCMReporting.log new file mode 100644 index 000000000..928610a0a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-compliance/current/DCMReporting.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-content/current/CAS.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-content/current/CAS.log new file mode 100644 index 000000000..671b26975 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-content/current/CAS.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-evaluation/current/CcmEval.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-evaluation/current/CcmEval.log new file mode 100644 index 000000000..65c854e45 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-identity/current/ClientIDManagerStartup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-identity/current/ClientIDManagerStartup.log new file mode 100644 index 000000000..cdfa8e94c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-inventory/current/InventoryAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-inventory/current/InventoryAgent.log new file mode 100644 index 000000000..37ed51063 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-inventory/current/InventoryAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..e32513a8c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-maintenance-window/current/ServiceWindowManager.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-maintenance-window/current/ServiceWindowManager.log new file mode 100644 index 000000000..6a06d423c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-maintenance-window/current/ServiceWindowManager.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-metering/current/SWMTRReportGen.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-metering/current/SWMTRReportGen.log new file mode 100644 index 000000000..9f95bcf50 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-metering/current/SWMTRReportGen.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..31cbd27d7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-policy-state/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-policy-state/current/CIAgent.log new file mode 100644 index 000000000..046421b84 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-policy-state/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-reboot/current/RebootCoordinator.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-reboot/current/RebootCoordinator.log new file mode 100644 index 000000000..236060c2f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-reboot/current/RebootCoordinator.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-task-sequence-smsts/client/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-task-sequence-smsts/client/current/smsts.log new file mode 100644 index 000000000..6791fce25 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-task-sequence-smsts/client/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..85c13d820 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-windows-update-supplemental/current/ReportingEvents.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-windows-update-supplemental/current/ReportingEvents.log new file mode 100644 index 000000000..d5612fe29 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-windows-update-supplemental/current/ReportingEvents.log @@ -0,0 +1 @@ +SYNTHETIC FIXTURE coverage only: supplemental update evidence is explicitly captured. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json new file mode 100644 index 000000000..4d6bb6420 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json @@ -0,0 +1,508 @@ +{ + "contractState": "pureIntakeImplementedNativePending", + "scenario": "complete", + "pureAssessment": { + "schemaVersion": 1, + "groups": [ + { + "logicalArtifactId": "client-app-enforce", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-app-enforce-root-a-current" + ] + }, + { + "logicalArtifactId": "client-app-intent", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-app-intent-root-a-current" + ] + }, + { + "logicalArtifactId": "client-ccmsetup", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-ccmsetup-root-a-current" + ] + }, + { + "logicalArtifactId": "client-compliance", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-report-root-a-numbered-04" + ] + }, + { + "logicalArtifactId": "client-content", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-content-root-a-current", + "fixture-complete-location-services-root-a-current" + ] + }, + { + "logicalArtifactId": "client-evaluation", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-evaluation-root-a-current" + ] + }, + { + "logicalArtifactId": "client-identity", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-identity-root-a-current" + ] + }, + { + "logicalArtifactId": "client-inventory", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-report-root-a-numbered-03" + ] + }, + { + "logicalArtifactId": "client-location", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-location-services-root-a-current" + ] + }, + { + "logicalArtifactId": "client-location-services-shared", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-location-services-root-a-current" + ] + }, + { + "logicalArtifactId": "client-maintenance-window", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-updates-root-a-numbered-01" + ] + }, + { + "logicalArtifactId": "client-metering", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-report-root-a-numbered-05" + ] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-policy-agent-root-a-current" + ] + }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-client-root-a-current" + ] + }, + { + "logicalArtifactId": "client-policy-state", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-policy-state-root-a-current" + ] + }, + { + "logicalArtifactId": "client-reboot", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-updates-root-a-numbered-02" + ] + }, + { + "logicalArtifactId": "client-updates", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-updates-root-a-current" + ] + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-update-supplemental-root-a-current" + ] + } + ], + "fragments": [ + { + "artifactId": "fixture-complete-app-enforce-root-a-current", + "basename": "AppEnforce.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-app-enforce", + "relativePath": "evidence/client-app-enforce/current/AppEnforce.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:00Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-app-intent-root-a-current", + "basename": "AppIntentEval.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-app-intent", + "relativePath": "evidence/client-app-intent/current/AppIntentEval.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:01Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-ccmsetup-root-a-current", + "basename": "ccmsetup.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-ccmsetup", + "relativePath": "evidence/client-ccmsetup/current/ccmsetup.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:02Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-client-root-a-current", + "basename": "smsts.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-client", + "relativePath": "evidence/client-task-sequence-smsts/client/current/smsts.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:08Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-content-root-a-current", + "basename": "CAS.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-content", + "relativePath": "evidence/client-content/current/CAS.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:03Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-evaluation-root-a-current", + "basename": "CcmEval.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-evaluation", + "relativePath": "evidence/client-evaluation/current/CcmEval.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:04Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-identity-root-a-current", + "basename": "ClientIDManagerStartup.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-identity", + "relativePath": "evidence/client-identity/current/ClientIDManagerStartup.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:05Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-location-services-root-a-current", + "basename": "LocationServices.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-location", + "relativePath": "evidence/client-location-services-shared/current/LocationServices.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:06Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-policy-agent-root-a-current", + "basename": "PolicyAgent.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-policy-agent", + "relativePath": "evidence/client-policy-agent/current/PolicyAgent.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:07Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-policy-state-root-a-current", + "basename": "CIAgent.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-policy-state", + "relativePath": "evidence/client-policy-state/current/CIAgent.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:08Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-report-root-a-numbered-03", + "basename": "InventoryAgent.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic:report-numbered-03", + "relativePath": "evidence/client-inventory/current/InventoryAgent.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:11Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-report-root-a-numbered-04", + "basename": "DCMReporting.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic:report-numbered-04", + "relativePath": "evidence/client-compliance/current/DCMReporting.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:12Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-report-root-a-numbered-05", + "basename": "SWMTRReportGen.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic:report-numbered-05", + "relativePath": "evidence/client-metering/current/SWMTRReportGen.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:13Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-update-supplemental-root-a-current", + "basename": "ReportingEvents.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-update-supplemental", + "relativePath": "evidence/client-windows-update-supplemental/current/ReportingEvents.log", + "fragmentComplete": true, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:00:10Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-updates-root-a-current", + "basename": "ScanAgent.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-updates", + "relativePath": "evidence/client-updates/current/ScanAgent.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:09Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-updates-root-a-numbered-01", + "basename": "ServiceWindowManager.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic:updates-numbered-01", + "relativePath": "evidence/client-maintenance-window/current/ServiceWindowManager.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:09Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-complete-updates-root-a-numbered-02", + "basename": "RebootCoordinator.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic:updates-numbered-02", + "relativePath": "evidence/client-reboot/current/RebootCoordinator.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:00:10Z", + "encoding": "utf-8" + } + ], + "physicalArtifactIds": [ + "fixture-complete-app-enforce-root-a-current", + "fixture-complete-app-intent-root-a-current", + "fixture-complete-ccmsetup-root-a-current", + "fixture-complete-client-root-a-current", + "fixture-complete-content-root-a-current", + "fixture-complete-evaluation-root-a-current", + "fixture-complete-identity-root-a-current", + "fixture-complete-location-services-root-a-current", + "fixture-complete-policy-agent-root-a-current", + "fixture-complete-policy-state-root-a-current", + "fixture-complete-update-supplemental-root-a-current", + "fixture-complete-updates-root-a-current", + "fixture-complete-report-root-a-numbered-03", + "fixture-complete-report-root-a-numbered-04", + "fixture-complete-report-root-a-numbered-05", + "fixture-complete-updates-root-a-numbered-01", + "fixture-complete-updates-root-a-numbered-02" + ], + "unsupportedArtifacts": [], + "coverageGaps": [] + }, + "nativeDesignPending": { + "artifactProvenance": [ + { + "artifactId": "fixture-complete-app-enforce-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 177 + }, + { + "artifactId": "fixture-complete-app-intent-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 177 + }, + { + "artifactId": "fixture-complete-ccmsetup-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 178 + }, + { + "artifactId": "fixture-complete-client-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 391 + }, + { + "artifactId": "fixture-complete-content-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 181 + }, + { + "artifactId": "fixture-complete-evaluation-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 184 + }, + { + "artifactId": "fixture-complete-identity-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 201 + }, + { + "artifactId": "fixture-complete-location-services-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 168 + }, + { + "artifactId": "fixture-complete-policy-agent-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 201 + }, + { + "artifactId": "fixture-complete-policy-state-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 177 + }, + { + "artifactId": "fixture-complete-report-root-a-numbered-03", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 178 + }, + { + "artifactId": "fixture-complete-report-root-a-numbered-04", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 177 + }, + { + "artifactId": "fixture-complete-report-root-a-numbered-05", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 177 + }, + { + "artifactId": "fixture-complete-update-supplemental-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 86 + }, + { + "artifactId": "fixture-complete-updates-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 174 + }, + { + "artifactId": "fixture-complete-updates-root-a-numbered-01", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 195 + }, + { + "artifactId": "fixture-complete-updates-root-a-numbered-02", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 180 + } + ] + }, + "downstreamDesignPending": { + "workflowDiagnosisExpected": false, + "requests": [], + "prohibitedClaims": [] + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json new file mode 100644 index 000000000..a42443a55 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json @@ -0,0 +1,31 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + {"artifactId":"fixture-complete-app-enforce-root-a-current","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log","pathFingerprint":"synthetic-app-enforce","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:00Z","bytesCopied":177,"relativePath":"evidence/client-app-enforce/current/AppEnforce.log"}, + {"artifactId":"fixture-complete-app-intent-root-a-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppIntentEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppIntentEval.log","pathFingerprint":"synthetic-app-intent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:01Z","bytesCopied":177,"relativePath":"evidence/client-app-intent/current/AppIntentEval.log"}, + {"artifactId":"fixture-complete-ccmsetup-root-a-current","designOnlyCatalog":{"entryId":"client-ccmsetup","groupMemberships":["client-ccmsetup"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"ccmsetup.log","sanitizedSourcePath":"SYNTHETIC://root-a/ccmsetup/Logs/ccmsetup.log","pathFingerprint":"synthetic-ccmsetup","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:02Z","bytesCopied":178,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"fixture-complete-content-root-a-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CAS.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CAS.log","pathFingerprint":"synthetic-content","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:03Z","bytesCopied":181,"relativePath":"evidence/client-content/current/CAS.log"}, + {"artifactId":"fixture-complete-evaluation-root-a-current","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CcmEval.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CcmEval.log","pathFingerprint":"synthetic-evaluation","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:04Z","bytesCopied":184,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"fixture-complete-identity-root-a-current","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/ClientIDManagerStartup.log","pathFingerprint":"synthetic-identity","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:05Z","bytesCopied":201,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"fixture-complete-location-services-root-a-current","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"LocationServices.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/LocationServices.log","pathFingerprint":"synthetic-location","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:06Z","bytesCopied":168,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"}, + {"artifactId":"fixture-complete-updates-root-a-numbered-01","designOnlyCatalog":{"entryId":"client-maintenance-window","groupMemberships":["client-maintenance-window"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"ServiceWindowManager.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/ServiceWindowManager.log","pathFingerprint":"synthetic:updates-numbered-01","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:09Z","bytesCopied":195,"relativePath":"evidence/client-maintenance-window/current/ServiceWindowManager.log"}, + {"artifactId":"fixture-complete-policy-agent-root-a-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic-policy-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:07Z","bytesCopied":201,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"fixture-complete-client-root-a-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/smsts.log","pathFingerprint":"synthetic-client","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:08Z","bytesCopied":391,"relativePath":"evidence/client-task-sequence-smsts/client/current/smsts.log"}, + {"artifactId":"fixture-complete-policy-state-root-a-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic-policy-state","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:08Z","bytesCopied":177,"relativePath":"evidence/client-policy-state/current/CIAgent.log"}, + {"artifactId":"fixture-complete-updates-root-a-numbered-02","designOnlyCatalog":{"entryId":"client-reboot","groupMemberships":["client-reboot"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"RebootCoordinator.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/RebootCoordinator.log","pathFingerprint":"synthetic:updates-numbered-02","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:10Z","bytesCopied":180,"relativePath":"evidence/client-reboot/current/RebootCoordinator.log"}, + {"artifactId":"fixture-complete-updates-root-a-current","designOnlyCatalog":{"entryId":"client-updates","groupMemberships":["client-updates"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"ScanAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/ScanAgent.log","pathFingerprint":"synthetic-updates","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:09Z","bytesCopied":174,"relativePath":"evidence/client-updates/current/ScanAgent.log"}, + {"artifactId":"fixture-complete-update-supplemental-root-a-current","designOnlyCatalog":{"entryId":"client-windows-update-supplemental","groupMemberships":["client-windows-update-supplemental"]},"role":"client","kind":"supplementalLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"ReportingEvents.log","sanitizedSourcePath":"SYNTHETIC://root-a/Windows/ReportingEvents.log","pathFingerprint":"synthetic-update-supplemental","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":null,"capturedUtc":"2026-07-30T00:00:10Z","bytesCopied":86,"relativePath":"evidence/client-windows-update-supplemental/current/ReportingEvents.log"}, + {"artifactId":"fixture-complete-report-root-a-numbered-03","designOnlyCatalog":{"entryId":"client-inventory","groupMemberships":["client-inventory"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"InventoryAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log","pathFingerprint":"synthetic:report-numbered-03","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:11Z","bytesCopied":178,"relativePath":"evidence/client-inventory/current/InventoryAgent.log"}, + {"artifactId":"fixture-complete-report-root-a-numbered-04","designOnlyCatalog":{"entryId":"client-compliance","groupMemberships":["client-compliance"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"DCMReporting.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/DCMReporting.log","pathFingerprint":"synthetic:report-numbered-04","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:12Z","bytesCopied":177,"relativePath":"evidence/client-compliance/current/DCMReporting.log"}, + {"artifactId":"fixture-complete-report-root-a-numbered-05","designOnlyCatalog":{"entryId":"client-metering","groupMemberships":["client-metering"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"SWMTRReportGen.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log","pathFingerprint":"synthetic:report-numbered-05","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:00:13Z","bytesCopied":177,"relativePath":"evidence/client-metering/current/SWMTRReportGen.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/missing-root/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/missing-root/expected.json new file mode 100644 index 000000000..d66f9fbed --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/missing-root/expected.json @@ -0,0 +1,434 @@ +{ + "contractState": "pureIntakeImplementedNativePending", + "scenario": "missing-root", + "pureAssessment": { + "schemaVersion": 1, + "groups": [ + { + "logicalArtifactId": "client-app-enforce", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-app-enforce-current" + ] + }, + { + "logicalArtifactId": "client-app-intent", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-app-intent-current" + ] + }, + { + "logicalArtifactId": "client-ccmsetup", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-ccmsetup-current" + ] + }, + { + "logicalArtifactId": "client-compliance", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-content", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-content-current", + "fixture-missing-location-services-current" + ] + }, + { + "logicalArtifactId": "client-evaluation", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-evaluation-current" + ] + }, + { + "logicalArtifactId": "client-identity", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-identity-current" + ] + }, + { + "logicalArtifactId": "client-inventory", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-location-services-current" + ] + }, + { + "logicalArtifactId": "client-location-services-shared", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-location-services-current" + ] + }, + { + "logicalArtifactId": "client-maintenance-window", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-metering", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-policy-agent-current" + ] + }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-state", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-policy-state-current" + ] + }, + { + "logicalArtifactId": "client-reboot", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-updates", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-updates-current" + ] + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-update-supplemental-current" + ] + } + ], + "fragments": [ + { + "artifactId": "fixture-missing-app-enforce-current", + "basename": "AppEnforce.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:00Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-app-intent-current", + "basename": "AppIntentEval.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:01Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-ccmsetup-current", + "basename": "ccmsetup.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:02Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-content-current", + "basename": "CAS.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:03Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-evaluation-current", + "basename": "CcmEval.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:04Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-identity-current", + "basename": "ClientIDManagerStartup.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:05Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-location-services-current", + "basename": "LocationServices.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:06Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-policy-agent-current", + "basename": "PolicyAgent.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:07Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-policy-state-current", + "basename": "CIAgent.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:08Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-update-supplemental-current", + "basename": "ReportingEvents.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:10Z", + "encoding": null + }, + { + "artifactId": "fixture-missing-updates-current", + "basename": "ScanAgent.log", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "pathFingerprint": null, + "relativePath": null, + "fragmentComplete": false, + "configmgrVersion": null, + "collectedAtUtc": "2026-07-30T00:04:09Z", + "encoding": null + } + ], + "physicalArtifactIds": [], + "unsupportedArtifacts": [], + "coverageGaps": [ + { + "logicalArtifactId": "client-app-enforce", + "artifactId": "fixture-missing-app-enforce-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source AppEnforce.log was supplied." + }, + { + "logicalArtifactId": "client-app-intent", + "artifactId": "fixture-missing-app-intent-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source AppIntentEval.log was supplied." + }, + { + "logicalArtifactId": "client-ccmsetup", + "artifactId": "fixture-missing-ccmsetup-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source ccmsetup.log was supplied." + }, + { + "logicalArtifactId": "client-compliance", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-content", + "artifactId": "fixture-missing-content-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source CAS.log was supplied." + }, + { + "logicalArtifactId": "client-content", + "artifactId": "fixture-missing-location-services-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source LocationServices.log was supplied." + }, + { + "logicalArtifactId": "client-evaluation", + "artifactId": "fixture-missing-evaluation-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source CcmEval.log was supplied." + }, + { + "logicalArtifactId": "client-identity", + "artifactId": "fixture-missing-identity-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source ClientIDManagerStartup.log was supplied." + }, + { + "logicalArtifactId": "client-inventory", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-location", + "artifactId": "fixture-missing-location-services-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source LocationServices.log was supplied." + }, + { + "logicalArtifactId": "client-location-services-shared", + "artifactId": "fixture-missing-location-services-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source LocationServices.log was supplied." + }, + { + "logicalArtifactId": "client-maintenance-window", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-metering", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-policy-agent", + "artifactId": "fixture-missing-policy-agent-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source PolicyAgent.log was supplied." + }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-policy-state", + "artifactId": "fixture-missing-policy-state-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source CIAgent.log was supplied." + }, + { + "logicalArtifactId": "client-reboot", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-updates", + "artifactId": "fixture-missing-updates-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source ScanAgent.log was supplied." + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "artifactId": "fixture-missing-update-supplemental-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source ReportingEvents.log was supplied." + } + ] + }, + "nativeDesignPending": { + "artifactProvenance": [] + }, + "downstreamDesignPending": { + "workflowDiagnosisExpected": false, + "requests": [ + { + "kind": "intakeCoverage", + "reason": "No configured client root was discovered." + } + ], + "prohibitedClaims": [ + "client not installed", + "client healthy", + "client failing" + ] + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/missing-root/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/missing-root/manifest.json new file mode 100644 index 000000000..cda178068 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/missing-root/manifest.json @@ -0,0 +1,19 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"fixture-missing-app-enforce-current","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"AppEnforce.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:00Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-app-intent-current","designOnlyCatalog":{"entryId":"client-app-intent","groupMemberships":["client-app-intent"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"AppIntentEval.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:01Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-ccmsetup-current","designOnlyCatalog":{"entryId":"client-ccmsetup","groupMemberships":["client-ccmsetup"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"ccmsetup.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:02Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-content-current","designOnlyCatalog":{"entryId":"client-content","groupMemberships":["client-content"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"CAS.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:03Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-evaluation-current","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"CcmEval.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:04Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-identity-current","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:05Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-location-services-current","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:06Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-policy-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"PolicyAgent.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:07Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-policy-state-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"CIAgent.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:08Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-updates-current","designOnlyCatalog":{"entryId":"client-updates","groupMemberships":["client-updates"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"ScanAgent.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:09Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"fixture-missing-update-supplemental-current","designOnlyCatalog":{"entryId":"client-windows-update-supplemental","groupMemberships":["client-windows-update-supplemental"]},"role":"client","kind":"supplementalLog","captureState":"absent","originalBasename":"ReportingEvents.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T00:04:10Z","bytesCopied":0,"relativePath":null} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/current/AppEnforce.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/current/AppEnforce.log new file mode 100644 index 000000000..53491d2d3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/current/AppEnforce.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/AppEnforce.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/AppEnforce.lo_ new file mode 100644 index 000000000..e34f04d74 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/AppEnforce.lo_ @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/numbered-2/AppEnforce.log.2 b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/numbered-2/AppEnforce.log.2 new file mode 100644 index 000000000..d4b7bc6c2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/numbered-2/AppEnforce.log.2 @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json new file mode 100644 index 000000000..97a4876eb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json @@ -0,0 +1,305 @@ +{ + "contractState": "pureIntakeImplementedNativePending", + "scenario": "rotations", + "pureAssessment": { + "schemaVersion": 1, + "groups": [ + { + "logicalArtifactId": "client-app-enforce", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-rotations-app-enforce-root-a-current", + "fixture-rotations-app-enforce-root-a-lo", + "fixture-rotations-app-enforce-root-a-numbered-2" + ] + }, + { + "logicalArtifactId": "client-app-intent", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-ccmsetup", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-compliance", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-content", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-evaluation", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-identity", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-inventory", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location-services-shared", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-maintenance-window", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-metering", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-state", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-reboot", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-updates", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "coverage": "absent", + "fragmentArtifactIds": [] + } + ], + "fragments": [ + { + "artifactId": "fixture-rotations-app-enforce-root-a-current", + "basename": "AppEnforce.log", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-root-a", + "rotationLineage": "synthetic:app-enforce-root-a", + "relativePath": "evidence/client-app-enforce/current/AppEnforce.log", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:01:02Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-rotations-app-enforce-root-a-lo", + "basename": "AppEnforce.lo_", + "rotation": { + "kind": "loUnderscore" + }, + "coverage": "captured", + "pathFingerprint": "synthetic-root-a", + "rotationLineage": "synthetic:app-enforce-root-a", + "relativePath": "evidence/client-app-enforce/lo/AppEnforce.lo_", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:01:01Z", + "encoding": "utf-8" + }, + { + "artifactId": "fixture-rotations-app-enforce-root-a-numbered-2", + "basename": "AppEnforce.log.2", + "rotation": { + "kind": "numbered", + "value": 2 + }, + "coverage": "captured", + "pathFingerprint": "synthetic-root-a", + "rotationLineage": "synthetic:app-enforce-root-a", + "relativePath": "evidence/client-app-enforce/numbered-2/AppEnforce.log.2", + "fragmentComplete": true, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:01:00Z", + "encoding": "utf-8" + } + ], + "physicalArtifactIds": [ + "fixture-rotations-app-enforce-root-a-current", + "fixture-rotations-app-enforce-root-a-lo", + "fixture-rotations-app-enforce-root-a-numbered-2" + ], + "unsupportedArtifacts": [], + "coverageGaps": [ + { + "logicalArtifactId": "client-app-intent", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-ccmsetup", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-compliance", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-content", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-evaluation", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-identity", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-inventory", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-location", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-location-services-shared", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-maintenance-window", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-metering", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-policy-agent", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-policy-state", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-reboot", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-updates", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "artifactId": null, + "role": "client", + "coverage": "absent", + "reason": "No artifact for this bounded client source group was supplied." + } + ] + }, + "nativeDesignPending": { + "artifactProvenance": [ + { + "artifactId": "fixture-rotations-app-enforce-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 181 + }, + { + "artifactId": "fixture-rotations-app-enforce-root-a-lo", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 176 + }, + { + "artifactId": "fixture-rotations-app-enforce-root-a-numbered-2", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 182 + } + ] + }, + "downstreamDesignPending": { + "workflowDiagnosisExpected": false, + "requests": [], + "prohibitedClaims": [] + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json new file mode 100644 index 000000000..b4505d00b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"fixture-rotations-app-enforce-root-a-current","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log","pathFingerprint":"synthetic-root-a","rotation":{"kind":"current","lineageId":"synthetic:app-enforce-root-a","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:02Z","bytesCopied":181,"relativePath":"evidence/client-app-enforce/current/AppEnforce.log"}, + {"artifactId":"fixture-rotations-app-enforce-root-a-lo","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.lo_","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.lo_","pathFingerprint":"synthetic-root-a","rotation":{"kind":"lo","lineageId":"synthetic:app-enforce-root-a","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:01Z","bytesCopied":176,"relativePath":"evidence/client-app-enforce/lo/AppEnforce.lo_"}, + {"artifactId":"fixture-rotations-app-enforce-root-a-numbered-2","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log.2","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log.2","pathFingerprint":"synthetic-root-a","rotation":{"kind":"numbered","number":2,"lineageId":"synthetic:app-enforce-root-a","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:00Z","bytesCopied":182,"relativePath":"evidence/client-app-enforce/numbered-2/AppEnforce.log.2"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/README.md new file mode 100644 index 000000000..0ef934104 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/README.md @@ -0,0 +1,55 @@ +# SCCM client inventory, compliance, and metering preparation corpus + +This issue-#325 corpus is synthetic, sanitized, deterministic, and +`proposedPending318And319`. The exported production analyzer exercises every +admissible scenario through sealed intake. Its explicit test adapter maps only +the reviewed `5.00.TEST.325` fixture version to the experimental production +profile and records the fixture-to-admitted artifact identity map used by exact +assertions. Unknown profiles and invalid timestamps are rejected rather than +rewritten as coverage. No fixture claims live Windows acceptance. + +The three top-level directories are independent workflow families: + +- `inventory`: Collect -> Provider -> Serialize -> Queue -> Report +- `compliance`: Evaluate -> Remediate -> Report +- `metering`: Collect -> Aggregate -> Report + +Every scenario contains: + +- `manifest.json`: additive SCCM-specific artifact, coverage, rotation, cap, + source-version, and provenance design; +- `expected.json`: proposed exact-key transaction or source-local coverage + outcomes with cited evidence, closed non-causal schemas, evidence-backed phase + state, canonical output ordering, and an exact SHA-256 oracle over the complete + normalized serialized production result (or the exact admission error for a + rejected scenario); +- optional `evidence/`: raw CCM transport records or deliberately incomplete + synthetic input. + +The preparation validator treats each `(captureHost, sanitizedSourcePath, +rotation)` tuple as one source identity in every scenario. Synthetic root +labels must agree across the sanitized path, fingerprint, and relative evidence +path; retained-byte fields are exact for the declared capture state. Cited CCM +fields and source-to-phase ownership are closed, evidence line ranges cannot +overlap, and filesystem separators are normalized before manifest comparison. +Artifact, transaction, and observation identities are bounded canonical +lowercase tokens scoped to their active family (and scenario for artifacts). +Source versions must be canonical tokens before profile-prefix selection, and +one `(observation kind, artifact)` membership can appear only once. A +`rotationSplit` observation additionally requires one common synthetic root, +canonical basename, source version, and exact family key across its `current` +and `.lo` fragments. + +Do not add real tenant, device, user, domain, path, package, baseline, or rule +identifiers. Do not use these fixtures to admit production catalog sources +until #318/#319 contracts and the relevant extraction profile have been +reviewed. + +Validation: + +```bash +cargo test --locked -p cmtraceopen-parser \ + --test sccm_client_inventory_compliance_metering_fixture_contract +cargo test --locked -p cmtraceopen-parser \ + --test sccm_client_inventory +``` diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/evidence/client-compliance/root-a/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/evidence/client-compliance/root-a/current/CIAgent.log new file mode 100644 index 000000000..ed504200f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/evidence/client-compliance/root-a/current/CIAgent.log @@ -0,0 +1 @@ +SYNTHETIC MALFORMED CCM Family=compliance diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/evidence/client-compliance/root-a/current/CITaskMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/evidence/client-compliance/root-a/current/CITaskMgr.log new file mode 100644 index 000000000..7a4d94650 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/evidence/client-compliance/root-a/current/CITaskMgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/evidence/client-compliance/root-a/current/DCMReporting.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/evidence/client-compliance/root-a/current/DCMReporting.log new file mode 100644 index 000000000..3d6f401fe --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/evidence/client-compliance/root-a/current/DCMReporting.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/expected.json new file mode 100644 index 000000000..dc555ff26 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/expected.json @@ -0,0 +1,69 @@ +{ + "productionOutputSha256": null, + "productionAdmissionError": "fixture-update-numbered-03: client intake artifact ConfigMgr version is unsafe or too long", + "contractState": "proposedPending318And319", + "scenario": "malformed-unknown-profile-invalid-offset", + "workflow": "compliance", + "extractionProfile": { + "id": "sccm-client-compliance-5.00.test-v1", + "selectionState": "mixedKnownAndUnknown", + "versionPrefix": "5.00.TEST." + }, + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "compliance-invalid-offset", + "kind": "invalidOffset", + "artifactIds": [ + "compliance-malformed-unknown-profile-invalid-offset-invalid-offset" + ], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Invalid timestamp offset cannot support ordered or high-confidence workflow claims." + }, + { + "observationId": "compliance-malformed", + "kind": "malformedRecord", + "artifactIds": [ + "compliance-malformed-unknown-profile-invalid-offset-malformed" + ], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Malformed CCM remains a parse coverage state." + }, + { + "observationId": "compliance-unknown-profile", + "kind": "unknownProfile", + "artifactIds": [ + "compliance-malformed-unknown-profile-invalid-offset-unknown-version" + ], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Unknown source version has no selected extraction profile." + } + ], + "coverage": [ + { + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", + "logicalArtifactId": "client-compliance", + "state": "captured" + }, + { + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-malformed", + "logicalArtifactId": "client-compliance", + "state": "parseFailed" + }, + { + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-unknown-version", + "logicalArtifactId": "client-compliance", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/manifest.json new file mode 100644 index 000000000..18fc92ccb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/manifest.json @@ -0,0 +1,102 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "malformed-unknown-profile-invalid-offset", + "workflowFamily": "compliance", + "bundle": { + "bundleId": "sccm-325-compliance-malformed-unknown-profile-invalid-offset", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "DCMReporting.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DCMReporting.log", + "pathFingerprint": "synthetic-compliance-malformed-unknown-profile-invalid-offset-invalid-offset-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 647, + "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-malformed", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "parseFailed", + "originalBasename": "CIAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CIAgent.log", + "pathFingerprint": "synthetic-compliance-malformed-unknown-profile-invalid-offset-malformed-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 42, + "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-unknown-version", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "CITaskMgr.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CITaskMgr.log", + "pathFingerprint": "synthetic-compliance-malformed-unknown-profile-invalid-offset-unknown-version-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "9.99.UNKNOWN", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 329, + "relativePath": "evidence/client-compliance/root-a/current/CITaskMgr.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/evidence/client-compliance/root-a/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/evidence/client-compliance/root-a/current/CIAgent.log new file mode 100644 index 000000000..c16f22703 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/evidence/client-compliance/root-a/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/expected.json new file mode 100644 index 000000000..6c73b6124 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/expected.json @@ -0,0 +1,56 @@ +{ + "productionOutputSha256": "f32c596bc2ae969d74f3d226ff9c9c5ebb2304f77a15df57cafbc0b23eab893d", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "noncompliant-result", + "workflow": "compliance", + "extractionProfile": { + "id": "sccm-client-compliance-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "compliance-noncompliant", + "workflow": "compliance", + "key": { + "CiId": "CI-002", + "BaselineId": "BASELINE-002", + "StateId": "STATE-002", + "ResourceHandle": "safe:resource:compliance-002", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Evaluate", + "state": "evaluatedNonCompliant", + "classification": "evaluationResult", + "confidence": "high", + "lastSuccessfulPhase": "Evaluate", + "evidence": [ + { + "artifactId": "compliance-noncompliant-result-agent-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "compliance-noncompliant-result-agent-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/manifest.json new file mode 100644 index 000000000..ddfed32d8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/manifest.json @@ -0,0 +1,46 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "noncompliant-result", + "workflowFamily": "compliance", + "bundle": { + "bundleId": "sccm-325-compliance-noncompliant-result", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "compliance-noncompliant-result-agent-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "CIAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CIAgent.log", + "pathFingerprint": "synthetic-compliance-noncompliant-result-agent-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 327, + "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/evidence/client-compliance/root-a/current/DCMReporting.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/evidence/client-compliance/root-a/current/DCMReporting.log new file mode 100644 index 000000000..e6412ae7c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/evidence/client-compliance/root-a/current/DCMReporting.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/expected.json new file mode 100644 index 000000000..5c071d5c0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/expected.json @@ -0,0 +1,93 @@ +{ + "productionOutputSha256": "9420cd58fec433b10b7d79dcee80d031238a64bd0e054bc1f2e0d1511c5e256e", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "recovery-contradictory", + "workflow": "compliance", + "extractionProfile": { + "id": "sccm-client-compliance-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "compliance-recovered", + "workflow": "compliance", + "key": { + "CiId": "CI-020", + "BaselineId": "BASELINE-020", + "StateId": "STATE-020", + "ResourceHandle": "safe:resource:compliance-020", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "recovered", + "classification": "recovery", + "confidence": "medium", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "compliance-recovery-contradictory-report-current", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "compliance-recovery-contradictory-report-current", + "startLine": 2, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + }, + { + "transactionId": "compliance-contradictory", + "workflow": "compliance", + "key": { + "CiId": "CI-021", + "BaselineId": "BASELINE-021", + "StateId": "STATE-021", + "ResourceHandle": "safe:resource:compliance-021", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Evaluate", + "state": "contradictory", + "classification": "symptom", + "confidence": "low", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "compliance-recovery-contradictory-report-current", + "startLine": 3, + "endLine": 3 + }, + { + "artifactId": "compliance-recovery-contradictory-report-current", + "startLine": 4, + "endLine": 4 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "compliance-recovery-contradictory-report-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/manifest.json new file mode 100644 index 000000000..ea59d8b2d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/manifest.json @@ -0,0 +1,46 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "recovery-contradictory", + "workflowFamily": "compliance", + "bundle": { + "bundleId": "sccm-325-compliance-recovery-contradictory", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "compliance-recovery-contradictory-report-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "DCMReporting.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DCMReporting.log", + "pathFingerprint": "synthetic-compliance-recovery-contradictory-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 1331, + "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/CIAgent.log new file mode 100644 index 000000000..6c6cffb4f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/DCMAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/DCMAgent.log new file mode 100644 index 000000000..45de1461d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/DCMAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/DCMReporting.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/DCMReporting.log new file mode 100644 index 000000000..51e62cda7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/DCMReporting.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/expected.json new file mode 100644 index 000000000..f9e5bbed8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/expected.json @@ -0,0 +1,66 @@ +{ + "productionOutputSha256": "84244f9832595d4deb2189626c8751baf837ac988883ee3b34dcc04e7f5380c0", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "remediation-success", + "workflow": "compliance", + "extractionProfile": { + "id": "sccm-client-compliance-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "compliance-remediated", + "workflow": "compliance", + "key": { + "CiId": "CI-003", + "BaselineId": "BASELINE-003", + "StateId": "STATE-003", + "ResourceHandle": "safe:resource:compliance-003", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "remediated", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "compliance-remediation-success-report-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "compliance-remediation-success-agent-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + }, + { + "artifactId": "compliance-remediation-success-remediate-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + }, + { + "artifactId": "compliance-remediation-success-report-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/manifest.json new file mode 100644 index 000000000..e79f7d2eb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/manifest.json @@ -0,0 +1,102 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "remediation-success", + "workflowFamily": "compliance", + "bundle": { + "bundleId": "sccm-325-compliance-remediation-success", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "compliance-remediation-success-agent-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "CIAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CIAgent.log", + "pathFingerprint": "synthetic-compliance-remediation-success-agent-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 328, + "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "compliance-remediation-success-remediate-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "DCMAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DCMAgent.log", + "pathFingerprint": "synthetic-compliance-remediation-success-remediate-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 305, + "relativePath": "evidence/client-compliance/root-a/current/DCMAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "compliance-remediation-success-report-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "DCMReporting.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DCMReporting.log", + "pathFingerprint": "synthetic-compliance-remediation-success-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 331, + "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/evidence/client-compliance/root-a/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/evidence/client-compliance/root-a/current/CIAgent.log new file mode 100644 index 000000000..28925ea5f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/evidence/client-compliance/root-a/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/evidence/client-compliance/root-b/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/evidence/client-compliance/root-b/current/CIAgent.log new file mode 100644 index 000000000..9188bb407 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/evidence/client-compliance/root-b/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/expected.json new file mode 100644 index 000000000..21d9cffbf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/expected.json @@ -0,0 +1,88 @@ +{ + "productionOutputSha256": "c0fcfd603b3e04b9aada02dd49d0ee9177bfdddb2f630cd31fcf98e496693a17", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "same-minute-collision", + "workflow": "compliance", + "extractionProfile": { + "id": "sccm-client-compliance-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "compliance-collision-a", + "workflow": "compliance", + "key": { + "CiId": "CI-030", + "BaselineId": "BASELINE-030", + "StateId": "STATE-030", + "ResourceHandle": "safe:resource:compliance-030", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Evaluate", + "state": "evaluatedNonCompliant", + "classification": "evaluationResult", + "confidence": "high", + "lastSuccessfulPhase": "Evaluate", + "evidence": [ + { + "artifactId": "compliance-same-minute-collision-root-a-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + }, + { + "transactionId": "compliance-collision-b", + "workflow": "compliance", + "key": { + "CiId": "CI-031", + "BaselineId": "BASELINE-031", + "StateId": "STATE-031", + "ResourceHandle": "safe:resource:compliance-031", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Evaluate", + "state": "evaluatedCompliant", + "classification": "evaluationResult", + "confidence": "high", + "lastSuccessfulPhase": "Evaluate", + "evidence": [ + { + "artifactId": "compliance-same-minute-collision-root-b-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "compliance-same-minute-collision-root-a-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + }, + { + "artifactId": "compliance-same-minute-collision-root-b-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/manifest.json new file mode 100644 index 000000000..fc7af5029 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/manifest.json @@ -0,0 +1,74 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "same-minute-collision", + "workflowFamily": "compliance", + "bundle": { + "bundleId": "sccm-325-compliance-same-minute-collision", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "compliance-same-minute-collision-root-a-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "CIAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CIAgent.log", + "pathFingerprint": "synthetic-compliance-same-minute-collision-root-a-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 327, + "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "compliance-same-minute-collision-root-b-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "CIAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-b/CCM/Logs/CIAgent.log", + "pathFingerprint": "synthetic-compliance-same-minute-collision-root-b-current-root-b", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 324, + "relativePath": "evidence/client-compliance/root-b/current/CIAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/evidence/client-compliance/root-a/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/evidence/client-compliance/root-a/current/CIAgent.log new file mode 100644 index 000000000..e57a3a1a9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/evidence/client-compliance/root-a/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/evidence/client-compliance/root-a/current/DCMReporting.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/evidence/client-compliance/root-a/current/DCMReporting.log new file mode 100644 index 000000000..09087a003 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/evidence/client-compliance/root-a/current/DCMReporting.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/expected.json new file mode 100644 index 000000000..a8784c08f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/expected.json @@ -0,0 +1,61 @@ +{ + "productionOutputSha256": "81f652065f278b8afac1e63b988fe2eeec9812c7a8f9dd0d84dd7f576789324b", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "success", + "workflow": "compliance", + "extractionProfile": { + "id": "sccm-client-compliance-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "compliance-success", + "workflow": "compliance", + "key": { + "CiId": "CI-001", + "BaselineId": "BASELINE-001", + "StateId": "STATE-001", + "ResourceHandle": "safe:resource:compliance-001", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "compliance-success-report-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "compliance-success-agent-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + }, + { + "artifactId": "compliance-success-report-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/manifest.json new file mode 100644 index 000000000..2c7594340 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/manifest.json @@ -0,0 +1,74 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "success", + "workflowFamily": "compliance", + "bundle": { + "bundleId": "sccm-325-compliance-success", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "compliance-success-agent-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "CIAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CIAgent.log", + "pathFingerprint": "synthetic-compliance-success-agent-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 325, + "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "compliance-success-report-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "DCMReporting.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DCMReporting.log", + "pathFingerprint": "synthetic-compliance-success-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 305, + "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/CIAgent.log new file mode 100644 index 000000000..a50cc3fb0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/DCMAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/DCMAgent.log new file mode 100644 index 000000000..140455b8b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/DCMAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/DCMReporting.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/DCMReporting.log new file mode 100644 index 000000000..c73adc211 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/DCMReporting.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/expected.json new file mode 100644 index 000000000..6bc8afdaf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/expected.json @@ -0,0 +1,132 @@ +{ + "productionOutputSha256": "fe1a43836ebdea8dff0db51d1f23c0e306404a5d5cd855825604d9a1ebb8db05", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "terminal-failures", + "workflow": "compliance", + "extractionProfile": { + "id": "sccm-client-compliance-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "compliance-evaluate-failed", + "workflow": "compliance", + "key": { + "CiId": "CI-010", + "BaselineId": "BASELINE-010", + "StateId": "STATE-010", + "ResourceHandle": "safe:resource:compliance-010", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Evaluate", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "compliance-terminal-failures-agent-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-compliance", + "sourceBasename": "CIAgent.log", + "reason": "Inspect the same exact compliance key in this admitted compliance source." + } + }, + { + "transactionId": "compliance-remediate-failed", + "workflow": "compliance", + "key": { + "CiId": "CI-011", + "BaselineId": "BASELINE-011", + "StateId": "STATE-011", + "ResourceHandle": "safe:resource:compliance-011", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Remediate", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "compliance-terminal-failures-remediate-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-compliance", + "sourceBasename": "DCMAgent.log", + "reason": "Inspect the same exact compliance key in this admitted compliance source." + } + }, + { + "transactionId": "compliance-report-failed", + "workflow": "compliance", + "key": { + "CiId": "CI-012", + "BaselineId": "BASELINE-012", + "StateId": "STATE-012", + "ResourceHandle": "safe:resource:compliance-012", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "compliance-terminal-failures-report-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-compliance", + "sourceBasename": "DCMReporting.log", + "reason": "Inspect the same exact compliance key in this admitted compliance source." + } + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "compliance-terminal-failures-agent-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + }, + { + "artifactId": "compliance-terminal-failures-remediate-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + }, + { + "artifactId": "compliance-terminal-failures-report-current", + "logicalArtifactId": "client-compliance", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/manifest.json new file mode 100644 index 000000000..57a802237 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/manifest.json @@ -0,0 +1,102 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "terminal-failures", + "workflowFamily": "compliance", + "bundle": { + "bundleId": "sccm-325-compliance-terminal-failures", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "compliance-terminal-failures-agent-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "CIAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CIAgent.log", + "pathFingerprint": "synthetic-compliance-terminal-failures-agent-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 320, + "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "compliance-terminal-failures-remediate-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "DCMAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DCMAgent.log", + "pathFingerprint": "synthetic-compliance-terminal-failures-remediate-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 322, + "relativePath": "evidence/client-compliance/root-a/current/DCMAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "compliance-terminal-failures-report-current", + "designOnlyCatalog": { + "entryId": "client-compliance", + "groupMemberships": [ + "client-compliance" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "DCMReporting.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DCMReporting.log", + "pathFingerprint": "synthetic-compliance-terminal-failures-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 323, + "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/evidence/client-inventory/root-a/current/InventoryAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/evidence/client-inventory/root-a/current/InventoryAgent.log new file mode 100644 index 000000000..d63579f21 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/evidence/client-inventory/root-a/current/InventoryAgent.log @@ -0,0 +1 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/expected.json new file mode 100644 index 000000000..608322e87 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/expected.json @@ -0,0 +1,91 @@ +{ + "productionOutputSha256": "7e4bd13a3cfbde74a79fabbf4f260972f48b30e643d3c5e1d9fbcca9f7c5ba72", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "recovery-contradictory", + "workflow": "inventory", + "extractionProfile": { + "id": "sccm-client-inventory-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "inventory-recovered", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-020", + "ResourceHandle": "safe:resource:inventory-020", + "ReportId": "INV-REPORT-020", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "recovered", + "classification": "recovery", + "confidence": "medium", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "inventory-recovery-contradictory-report-current", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "inventory-recovery-contradictory-report-current", + "startLine": 2, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + }, + { + "transactionId": "inventory-contradictory", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-021", + "ResourceHandle": "safe:resource:inventory-021", + "ReportId": "INV-REPORT-021", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "contradictory", + "classification": "symptom", + "confidence": "low", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "inventory-recovery-contradictory-report-current", + "startLine": 3, + "endLine": 3 + }, + { + "artifactId": "inventory-recovery-contradictory-report-current", + "startLine": 4, + "endLine": 4 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "inventory-recovery-contradictory-report-current", + "logicalArtifactId": "client-inventory", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/manifest.json new file mode 100644 index 000000000..34773a684 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/manifest.json @@ -0,0 +1,46 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "recovery-contradictory", + "workflowFamily": "inventory", + "bundle": { + "bundleId": "sccm-325-inventory-recovery-contradictory", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "inventory-recovery-contradictory-report-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryAgentProvider.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgentProvider.log", + "pathFingerprint": "synthetic-inventory-recovery-contradictory-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 1334, + "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/current/InventoryAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/current/InventoryAgent.log new file mode 100644 index 000000000..fa44fd16a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/current/InventoryAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/lo/InventoryAgent.log.lo b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/lo/InventoryAgent.log.lo new file mode 100644 index 000000000..cc9cecc7d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/lo/InventoryAgent.log.lo @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/expected.json new file mode 100644 index 000000000..e74c76936 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/expected.json @@ -0,0 +1,45 @@ +{ + "productionOutputSha256": "9235c11160611c61094b787b78e5320d3d5888f48ce6eb3715ee0fd5761b5055", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "rotation-boundary", + "workflow": "inventory", + "extractionProfile": { + "id": "sccm-client-inventory-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "inventory-rotation-split", + "kind": "rotationSplit", + "artifactIds": [ + "inventory-rotation-boundary-agent-current", + "inventory-rotation-boundary-agent-lo" + ], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Exact keys split only across incomplete rotation fragments cannot establish a complete workflow." + } + ], + "coverage": [ + { + "artifactId": "inventory-rotation-boundary-agent-current", + "logicalArtifactId": "client-inventory", + "state": "partial" + }, + { + "artifactId": "inventory-rotation-boundary-agent-lo", + "logicalArtifactId": "client-inventory", + "state": "partial" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/manifest.json new file mode 100644 index 000000000..f5e3083a8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/manifest.json @@ -0,0 +1,74 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "rotation-boundary", + "workflowFamily": "inventory", + "bundle": { + "bundleId": "sccm-325-inventory-rotation-boundary", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "inventory-rotation-boundary-agent-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log", + "pathFingerprint": "synthetic-inventory-rotation-boundary-agent-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 308, + "relativePath": "evidence/client-inventory/root-a/current/InventoryAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "inventory-rotation-boundary-agent-lo", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryAgent.log.lo", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log.lo", + "pathFingerprint": "synthetic-inventory-rotation-boundary-agent-lo-root-a", + "rotation": { + "kind": "lo", + "fragmentComplete": false + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 308, + "relativePath": "evidence/client-inventory/root-a/lo/InventoryAgent.log.lo", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/evidence/client-inventory/root-a/current/InventoryAgentProvider.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/evidence/client-inventory/root-a/current/InventoryAgentProvider.log new file mode 100644 index 000000000..3220ed72b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/evidence/client-inventory/root-a/current/InventoryAgentProvider.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/evidence/client-inventory/root-b/current/InventoryAgentProvider.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/evidence/client-inventory/root-b/current/InventoryAgentProvider.log new file mode 100644 index 000000000..bdc849fdd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/evidence/client-inventory/root-b/current/InventoryAgentProvider.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/expected.json new file mode 100644 index 000000000..16ab57896 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/expected.json @@ -0,0 +1,86 @@ +{ + "productionOutputSha256": "6d5fc31e1ed34fda055b44ce774c41a13c7a5f25cbe7e716c1cdd6744d7d9feb", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "same-minute-collision", + "workflow": "inventory", + "extractionProfile": { + "id": "sccm-client-inventory-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "inventory-collision-a", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-030", + "ResourceHandle": "safe:resource:inventory-030", + "ReportId": "INV-REPORT-030", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "inventory-same-minute-collision-root-a-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + }, + { + "transactionId": "inventory-collision-b", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-031", + "ResourceHandle": "safe:resource:inventory-031", + "ReportId": "INV-REPORT-031", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "inventory-same-minute-collision-root-b-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "inventory-same-minute-collision-root-a-current", + "logicalArtifactId": "client-inventory", + "state": "captured" + }, + { + "artifactId": "inventory-same-minute-collision-root-b-current", + "logicalArtifactId": "client-inventory", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/manifest.json new file mode 100644 index 000000000..8a67ee965 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/manifest.json @@ -0,0 +1,74 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "same-minute-collision", + "workflowFamily": "inventory", + "bundle": { + "bundleId": "sccm-325-inventory-same-minute-collision", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "inventory-same-minute-collision-root-a-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryAgentProvider.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgentProvider.log", + "pathFingerprint": "synthetic-inventory-same-minute-collision-root-a-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 314, + "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "inventory-same-minute-collision-root-b-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryAgentProvider.log", + "sanitizedSourcePath": "SYNTHETIC://root-b/CCM/Logs/InventoryAgentProvider.log", + "pathFingerprint": "synthetic-inventory-same-minute-collision-root-b-current-root-b", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 314, + "relativePath": "evidence/client-inventory/root-b/current/InventoryAgentProvider.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryAgent.log new file mode 100644 index 000000000..40f6e275b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryAgentProvider.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryAgentProvider.log new file mode 100644 index 000000000..0fc27639e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryAgentProvider.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryProvider.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryProvider.log new file mode 100644 index 000000000..12cf9d633 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryProvider.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/expected.json new file mode 100644 index 000000000..7e0a26b9f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/expected.json @@ -0,0 +1,65 @@ +{ + "productionOutputSha256": "c3e5ac62e40e0aa0d59e27d5defc0a764e79f25813c37bb7337e32732b4fcf93", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "success", + "workflow": "inventory", + "extractionProfile": { + "id": "sccm-client-inventory-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "inventory-success", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-001", + "ResourceHandle": "safe:resource:inventory-001", + "ReportId": "INV-REPORT-001", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "inventory-success-report-current", + "startLine": 2, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "inventory-success-agent-current", + "logicalArtifactId": "client-inventory", + "state": "captured" + }, + { + "artifactId": "inventory-success-provider-current", + "logicalArtifactId": "client-inventory", + "state": "captured" + }, + { + "artifactId": "inventory-success-report-current", + "logicalArtifactId": "client-inventory", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/manifest.json new file mode 100644 index 000000000..0d5b770d8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/manifest.json @@ -0,0 +1,102 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "success", + "workflowFamily": "inventory", + "bundle": { + "bundleId": "sccm-325-inventory-success", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "inventory-success-agent-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log", + "pathFingerprint": "synthetic-inventory-success-agent-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 308, + "relativePath": "evidence/client-inventory/root-a/current/InventoryAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "inventory-success-provider-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryProvider.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryProvider.log", + "pathFingerprint": "synthetic-inventory-success-provider-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 625, + "relativePath": "evidence/client-inventory/root-a/current/InventoryProvider.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "inventory-success-report-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryAgentProvider.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgentProvider.log", + "pathFingerprint": "synthetic-inventory-success-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 628, + "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryAgent.log new file mode 100644 index 000000000..1d0db7bb6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryAgentProvider.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryAgentProvider.log new file mode 100644 index 000000000..9d531635a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryAgentProvider.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryProvider.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryProvider.log new file mode 100644 index 000000000..4d5b88327 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryProvider.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/expected.json new file mode 100644 index 000000000..d1d8ad58a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/expected.json @@ -0,0 +1,189 @@ +{ + "productionOutputSha256": "630e9d564a327fb5f06f1cd67f7f96421fefbfc1dabfc4b12b3913d05647271d", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "terminal-failures", + "workflow": "inventory", + "extractionProfile": { + "id": "sccm-client-inventory-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "inventory-collect-failed", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-010", + "ResourceHandle": "safe:resource:inventory-010", + "ReportId": "INV-REPORT-010", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Collect", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "inventory-terminal-failures-agent-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-inventory", + "sourceBasename": "InventoryAgent.log", + "reason": "Inspect the same exact inventory key in this admitted inventory source." + } + }, + { + "transactionId": "inventory-provider-failed", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-011", + "ResourceHandle": "safe:resource:inventory-011", + "ReportId": "INV-REPORT-011", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Provider", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "inventory-terminal-failures-provider-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-inventory", + "sourceBasename": "InventoryProvider.log", + "reason": "Inspect the same exact inventory key in this admitted inventory source." + } + }, + { + "transactionId": "inventory-serialize-failed", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-012", + "ResourceHandle": "safe:resource:inventory-012", + "ReportId": "INV-REPORT-012", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Serialize", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "inventory-terminal-failures-provider-current", + "startLine": 2, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-inventory", + "sourceBasename": "InventoryProvider.log", + "reason": "Inspect the same exact inventory key in this admitted inventory source." + } + }, + { + "transactionId": "inventory-queue-failed", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-013", + "ResourceHandle": "safe:resource:inventory-013", + "ReportId": "INV-REPORT-013", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Queue", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "inventory-terminal-failures-report-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-inventory", + "sourceBasename": "InventoryAgentProvider.log", + "reason": "Inspect the same exact inventory key in this admitted inventory source." + } + }, + { + "transactionId": "inventory-report-failed", + "workflow": "inventory", + "key": { + "InventoryCycleId": "INV-CYCLE-014", + "ResourceHandle": "safe:resource:inventory-014", + "ReportId": "INV-REPORT-014", + "keyProfileKind": "inventoryExact", + "extractionProfileId": "sccm-client-inventory-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "inventory-terminal-failures-report-current", + "startLine": 2, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-inventory", + "sourceBasename": "InventoryAgentProvider.log", + "reason": "Inspect the same exact inventory key in this admitted inventory source." + } + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "inventory-terminal-failures-agent-current", + "logicalArtifactId": "client-inventory", + "state": "captured" + }, + { + "artifactId": "inventory-terminal-failures-provider-current", + "logicalArtifactId": "client-inventory", + "state": "captured" + }, + { + "artifactId": "inventory-terminal-failures-report-current", + "logicalArtifactId": "client-inventory", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/manifest.json new file mode 100644 index 000000000..abc3a943e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/manifest.json @@ -0,0 +1,102 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "terminal-failures", + "workflowFamily": "inventory", + "bundle": { + "bundleId": "sccm-325-inventory-terminal-failures", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "inventory-terminal-failures-agent-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log", + "pathFingerprint": "synthetic-inventory-terminal-failures-agent-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 325, + "relativePath": "evidence/client-inventory/root-a/current/InventoryAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "inventory-terminal-failures-provider-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryProvider.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryProvider.log", + "pathFingerprint": "synthetic-inventory-terminal-failures-provider-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 659, + "relativePath": "evidence/client-inventory/root-a/current/InventoryProvider.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "inventory-terminal-failures-report-current", + "designOnlyCatalog": { + "entryId": "client-inventory", + "groupMemberships": [ + "client-inventory" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "InventoryAgentProvider.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgentProvider.log", + "pathFingerprint": "synthetic-inventory-terminal-failures-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 663, + "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/evidence/client-metering/root-c/current/SWMTRReportGen.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/evidence/client-metering/root-c/current/SWMTRReportGen.log new file mode 100644 index 000000000..3365eab85 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/evidence/client-metering/root-c/current/SWMTRReportGen.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/expected.json new file mode 100644 index 000000000..c0c613ff7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/expected.json @@ -0,0 +1,60 @@ +{ + "productionOutputSha256": "6b41b847d6da16fd477e2f93e9924069e71a7764147ca9ce9957780e0398bd0f", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "deferred", + "workflow": "metering", + "extractionProfile": { + "id": "sccm-client-metering-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "metering-deferred", + "workflow": "metering", + "key": { + "MeteringCycleId": "METER-CYCLE-040", + "RuleId": "RULE-040", + "ReportId": "METER-REPORT-040", + "ResourceHandle": "safe:resource:metering-040", + "keyProfileKind": "meteringExact", + "extractionProfileId": "sccm-client-metering-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "blockedOrDeferred", + "classification": "blockedOrDeferred", + "confidence": "low", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "metering-deferred-report-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-metering", + "sourceBasename": "SWMTRReportGen.log", + "reason": "Inspect the same exact metering key in this admitted metering source." + } + } + ], + "coverage": [ + { + "artifactId": "metering-deferred-report-current", + "logicalArtifactId": "client-metering", + "state": "captured" + } + ], + "sourceLocalObservations": [], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/manifest.json new file mode 100644 index 000000000..2959b428a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/manifest.json @@ -0,0 +1,44 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "deferred", + "workflowFamily": "metering", + "bundle": { + "bundleId": "sccm-325-metering-deferred", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "metering-deferred-report-current", + "designOnlyCatalog": { + "entryId": "client-metering", + "groupMemberships": ["client-metering"] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "SWMTRReportGen.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-deferred-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 322, + "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/evidence/client-metering/root-a/current/SWMTRReportGen.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/evidence/client-metering/root-a/current/SWMTRReportGen.log new file mode 100644 index 000000000..0a7e715d9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/evidence/client-metering/root-a/current/SWMTRReportGen.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/expected.json new file mode 100644 index 000000000..9dc7e2259 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/expected.json @@ -0,0 +1,93 @@ +{ + "productionOutputSha256": "ed4eaf683538665546629ff84598aaa96bef0e0578e7777aa413f5200fa59525", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "recovery-contradictory", + "workflow": "metering", + "extractionProfile": { + "id": "sccm-client-metering-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "metering-recovered", + "workflow": "metering", + "key": { + "MeteringCycleId": "METER-CYCLE-020", + "RuleId": "RULE-020", + "ReportId": "METER-REPORT-020", + "ResourceHandle": "safe:resource:metering-020", + "keyProfileKind": "meteringExact", + "extractionProfileId": "sccm-client-metering-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "recovered", + "classification": "recovery", + "confidence": "medium", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "metering-recovery-contradictory-report-current", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "metering-recovery-contradictory-report-current", + "startLine": 2, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + }, + { + "transactionId": "metering-contradictory", + "workflow": "metering", + "key": { + "MeteringCycleId": "METER-CYCLE-021", + "RuleId": "RULE-021", + "ReportId": "METER-REPORT-021", + "ResourceHandle": "safe:resource:metering-021", + "keyProfileKind": "meteringExact", + "extractionProfileId": "sccm-client-metering-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "contradictory", + "classification": "symptom", + "confidence": "low", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "metering-recovery-contradictory-report-current", + "startLine": 3, + "endLine": 3 + }, + { + "artifactId": "metering-recovery-contradictory-report-current", + "startLine": 4, + "endLine": 4 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "metering-recovery-contradictory-report-current", + "logicalArtifactId": "client-metering", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/manifest.json new file mode 100644 index 000000000..3ca45aba0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/manifest.json @@ -0,0 +1,46 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "recovery-contradictory", + "workflowFamily": "metering", + "bundle": { + "bundleId": "sccm-325-metering-recovery-contradictory", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "metering-recovery-contradictory-report-current", + "designOnlyCatalog": { + "entryId": "client-metering", + "groupMemberships": [ + "client-metering" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "SWMTRReportGen.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-recovery-contradictory-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 1370, + "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/evidence/client-metering/root-a/current/SWMTRReportGen.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/evidence/client-metering/root-a/current/SWMTRReportGen.log new file mode 100644 index 000000000..35c489f15 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/evidence/client-metering/root-a/current/SWMTRReportGen.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/evidence/client-metering/root-a/lo/SWMTRReportGen.log.lo b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/evidence/client-metering/root-a/lo/SWMTRReportGen.log.lo new file mode 100644 index 000000000..65ea73a26 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/evidence/client-metering/root-a/lo/SWMTRReportGen.log.lo @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/expected.json new file mode 100644 index 000000000..a3b6e7ca5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/expected.json @@ -0,0 +1,45 @@ +{ + "productionOutputSha256": "d00f9262ce2a4cfeba47d5b29f4760b15d9b8d9f16efbec43cf3f4640e434bea", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "rotation-boundary", + "workflow": "metering", + "extractionProfile": { + "id": "sccm-client-metering-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "metering-rotation-split", + "kind": "rotationSplit", + "artifactIds": [ + "metering-rotation-boundary-report-current", + "metering-rotation-boundary-report-lo" + ], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Exact keys split only across incomplete rotation fragments cannot establish a complete workflow." + } + ], + "coverage": [ + { + "artifactId": "metering-rotation-boundary-report-current", + "logicalArtifactId": "client-metering", + "state": "partial" + }, + { + "artifactId": "metering-rotation-boundary-report-lo", + "logicalArtifactId": "client-metering", + "state": "partial" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/manifest.json new file mode 100644 index 000000000..257978af3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/manifest.json @@ -0,0 +1,74 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "rotation-boundary", + "workflowFamily": "metering", + "bundle": { + "bundleId": "sccm-325-metering-rotation-boundary", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "metering-rotation-boundary-report-current", + "designOnlyCatalog": { + "entryId": "client-metering", + "groupMemberships": [ + "client-metering" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "SWMTRReportGen.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-rotation-boundary-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 323, + "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "metering-rotation-boundary-report-lo", + "designOnlyCatalog": { + "entryId": "client-metering", + "groupMemberships": [ + "client-metering" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "SWMTRReportGen.log.lo", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log.lo", + "pathFingerprint": "synthetic-metering-rotation-boundary-report-lo-root-a", + "rotation": { + "kind": "lo", + "fragmentComplete": false + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 327, + "relativePath": "evidence/client-metering/root-a/lo/SWMTRReportGen.log.lo", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/evidence/client-metering/root-a/current/SWMTRReportGen.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/evidence/client-metering/root-a/current/SWMTRReportGen.log new file mode 100644 index 000000000..46034dcba --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/evidence/client-metering/root-a/current/SWMTRReportGen.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/evidence/client-metering/root-b/current/SWMTRReportGen.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/evidence/client-metering/root-b/current/SWMTRReportGen.log new file mode 100644 index 000000000..e0bf0698f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/evidence/client-metering/root-b/current/SWMTRReportGen.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/expected.json new file mode 100644 index 000000000..8ed30a1a6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/expected.json @@ -0,0 +1,88 @@ +{ + "productionOutputSha256": "f5d19ce7883b60b618a124b5a967038b5e956e77462ee4e848307a30319c41d8", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "same-minute-collision", + "workflow": "metering", + "extractionProfile": { + "id": "sccm-client-metering-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "metering-collision-a", + "workflow": "metering", + "key": { + "MeteringCycleId": "METER-CYCLE-030", + "RuleId": "RULE-030", + "ReportId": "METER-REPORT-030", + "ResourceHandle": "safe:resource:metering-030", + "keyProfileKind": "meteringExact", + "extractionProfileId": "sccm-client-metering-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "metering-same-minute-collision-root-a-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + }, + { + "transactionId": "metering-collision-b", + "workflow": "metering", + "key": { + "MeteringCycleId": "METER-CYCLE-031", + "RuleId": "RULE-031", + "ReportId": "METER-REPORT-031", + "ResourceHandle": "safe:resource:metering-031", + "keyProfileKind": "meteringExact", + "extractionProfileId": "sccm-client-metering-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "metering-same-minute-collision-root-b-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "metering-same-minute-collision-root-a-current", + "logicalArtifactId": "client-metering", + "state": "captured" + }, + { + "artifactId": "metering-same-minute-collision-root-b-current", + "logicalArtifactId": "client-metering", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/manifest.json new file mode 100644 index 000000000..90f3add55 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/manifest.json @@ -0,0 +1,74 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "same-minute-collision", + "workflowFamily": "metering", + "bundle": { + "bundleId": "sccm-325-metering-same-minute-collision", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "metering-same-minute-collision-root-a-current", + "designOnlyCatalog": { + "entryId": "client-metering", + "groupMemberships": [ + "client-metering" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "SWMTRReportGen.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-same-minute-collision-root-a-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 323, + "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "metering-same-minute-collision-root-b-current", + "designOnlyCatalog": { + "entryId": "client-metering", + "groupMemberships": [ + "client-metering" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "SWMTRReportGen.log", + "sanitizedSourcePath": "SYNTHETIC://root-b/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-same-minute-collision-root-b-current-root-b", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 323, + "relativePath": "evidence/client-metering/root-b/current/SWMTRReportGen.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/evidence/client-metering/root-a/current/SWMTRReportGen.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/evidence/client-metering/root-a/current/SWMTRReportGen.log new file mode 100644 index 000000000..1da78523c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/evidence/client-metering/root-a/current/SWMTRReportGen.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/expected.json new file mode 100644 index 000000000..167ec88b7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/expected.json @@ -0,0 +1,56 @@ +{ + "productionOutputSha256": "b0632a3ef1bc26003b2b20cb8aa46bbfff69dceed2332ab5ee9289867a1dce91", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "success", + "workflow": "metering", + "extractionProfile": { + "id": "sccm-client-metering-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "metering-success", + "workflow": "metering", + "key": { + "MeteringCycleId": "METER-CYCLE-001", + "RuleId": "RULE-001", + "ReportId": "METER-REPORT-001", + "ResourceHandle": "safe:resource:metering-001", + "keyProfileKind": "meteringExact", + "extractionProfileId": "sccm-client-metering-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "metering-success-report-current", + "startLine": 3, + "endLine": 3 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "metering-success-report-current", + "logicalArtifactId": "client-metering", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/manifest.json new file mode 100644 index 000000000..205d37f64 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/manifest.json @@ -0,0 +1,46 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "success", + "workflowFamily": "metering", + "bundle": { + "bundleId": "sccm-325-metering-success", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "metering-success-report-current", + "designOnlyCatalog": { + "entryId": "client-metering", + "groupMemberships": [ + "client-metering" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "SWMTRReportGen.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-success-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 975, + "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/evidence/client-metering/root-a/current/SWMTRReportGen.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/evidence/client-metering/root-a/current/SWMTRReportGen.log new file mode 100644 index 000000000..f0d11246f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/evidence/client-metering/root-a/current/SWMTRReportGen.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/expected.json new file mode 100644 index 000000000..8afb4c162 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/expected.json @@ -0,0 +1,122 @@ +{ + "productionOutputSha256": "b8bca74284d9201c60d534f40138519fc162181118482ec4b1e19c9085a3320b", + "productionAdmissionError": null, + "contractState": "proposedPending318And319", + "scenario": "terminal-failures", + "workflow": "metering", + "extractionProfile": { + "id": "sccm-client-metering-5.00.test-v1", + "selectionState": "selected", + "versionPrefix": "5.00.TEST." + }, + "transactions": [ + { + "transactionId": "metering-collect-failed", + "workflow": "metering", + "key": { + "MeteringCycleId": "METER-CYCLE-010", + "RuleId": "RULE-010", + "ReportId": "METER-REPORT-010", + "ResourceHandle": "safe:resource:metering-010", + "keyProfileKind": "meteringExact", + "extractionProfileId": "sccm-client-metering-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Collect", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "metering-terminal-failures-report-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-metering", + "sourceBasename": "SWMTRReportGen.log", + "reason": "Inspect the same exact metering key in this admitted metering source." + } + }, + { + "transactionId": "metering-aggregate-failed", + "workflow": "metering", + "key": { + "MeteringCycleId": "METER-CYCLE-011", + "RuleId": "RULE-011", + "ReportId": "METER-REPORT-011", + "ResourceHandle": "safe:resource:metering-011", + "keyProfileKind": "meteringExact", + "extractionProfileId": "sccm-client-metering-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Aggregate", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "metering-terminal-failures-report-current", + "startLine": 2, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-metering", + "sourceBasename": "SWMTRReportGen.log", + "reason": "Inspect the same exact metering key in this admitted metering source." + } + }, + { + "transactionId": "metering-report-failed", + "workflow": "metering", + "key": { + "MeteringCycleId": "METER-CYCLE-012", + "RuleId": "RULE-012", + "ReportId": "METER-REPORT-012", + "ResourceHandle": "safe:resource:metering-012", + "keyProfileKind": "meteringExact", + "extractionProfileId": "sccm-client-metering-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": null, + "evidence": [ + { + "artifactId": "metering-terminal-failures-report-current", + "startLine": 3, + "endLine": 3 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-metering", + "sourceBasename": "SWMTRReportGen.log", + "reason": "Inspect the same exact metering key in this admitted metering source." + } + } + ], + "sourceLocalObservations": [], + "coverage": [ + { + "artifactId": "metering-terminal-failures-report-current", + "logicalArtifactId": "client-metering", + "state": "captured" + } + ], + "findings": [], + "prohibitedClaims": [ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/manifest.json new file mode 100644 index 000000000..d17ae4bd0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/manifest.json @@ -0,0 +1,46 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "terminal-failures", + "workflowFamily": "metering", + "bundle": { + "bundleId": "sccm-325-metering-terminal-failures", + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "metering-terminal-failures-report-current", + "designOnlyCatalog": { + "entryId": "client-metering", + "groupMemberships": [ + "client-metering" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "originalBasename": "SWMTRReportGen.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-terminal-failures-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T04:00:00Z", + "bytesCopied": 1027, + "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/README.md new file mode 100644 index 000000000..8e4d4619f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/README.md @@ -0,0 +1,14 @@ +# SCCM client-management fixture corpus + +This directory contains synthetic, proposal-only contracts for issue #326. +Nothing here is a production extraction profile or evidence that a source was +validated on Windows. `CoManagementHandler.log`, `Scripts.log`, and +`CcmNotificationAgent.log` are admitted only for this deterministic test +profile. The sanitized `SCClient_SYNTHETIC_*.log` and +`SCNotify_SYNTHETIC_*.log` names are deliberately marked +`candidateUnsupported`; their records must not be parsed into operational +findings. + +Every semantic record contains `SYNTHETIC FIXTURE`. All identities, handles, +paths, versions, and signals are fictional. Physical fragments remain separate +through artifact IDs, relative paths, rotation state, and path fingerprints. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..d5401218d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/expected.json new file mode 100644 index 000000000..bff47eef7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/expected.json @@ -0,0 +1,40 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "co-management-intune-owned", + "workflow": "coManagement", + "extractionProfile": { + "id": "sccm-client-co-management-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "IntuneOwned", + "confidence": "high", + "terminalHandoff": true, + "workload": "Scripts", + "evidence": [ + { + "artifactId": "co-intune-owner-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "co-intune-owner-current", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/manifest.json new file mode 100644 index 000000000..a3ab14d53 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/manifest.json @@ -0,0 +1,39 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "co-management-intune-owned", + "workflowFamily": "coManagement", + "bundle": { + "bundleId": "sccm-326-co-management-intune-owned", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "co-intune-owner-current", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/co-management-intune-owned/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:co-intune-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..e1f125680 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/expected.json new file mode 100644 index 000000000..48e4a3a57 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/expected.json @@ -0,0 +1,40 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "co-management-sccm-owned", + "workflow": "coManagement", + "extractionProfile": { + "id": "sccm-client-co-management-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "SccmOwned", + "confidence": "high", + "terminalHandoff": false, + "workload": "Scripts", + "evidence": [ + { + "artifactId": "co-sccm-owner-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "co-sccm-owner-current", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/manifest.json new file mode 100644 index 000000000..f4228e6e9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/manifest.json @@ -0,0 +1,39 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "co-management-sccm-owned", + "workflowFamily": "coManagement", + "bundle": { + "bundleId": "sccm-326-co-management-sccm-owned", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "co-sccm-owner-current", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/co-management-sccm-owned/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:co-sccm-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..27a2efdee --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/expected.json new file mode 100644 index 000000000..5228ec7ab --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/expected.json @@ -0,0 +1,40 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "co-management-transitioning", + "workflow": "coManagement", + "extractionProfile": { + "id": "sccm-client-co-management-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "SharedOrTransitioning", + "confidence": "medium", + "terminalHandoff": false, + "workload": "Scripts", + "evidence": [ + { + "artifactId": "co-transition-owner-current", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "co-transition-owner-current", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/manifest.json new file mode 100644 index 000000000..2944fac37 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/manifest.json @@ -0,0 +1,39 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "co-management-transitioning", + "workflowFamily": "coManagement", + "bundle": { + "bundleId": "sccm-326-co-management-transitioning", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "co-transition-owner-current", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/co-management-transitioning/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:co-transition-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/expected.json new file mode 100644 index 000000000..e91667f70 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/expected.json @@ -0,0 +1,47 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "co-management-unknown", + "workflow": "coManagement", + "extractionProfile": { + "id": "sccm-client-co-management-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "UnknownOwnership", + "confidence": "low", + "terminalHandoff": false, + "workload": "Scripts", + "evidence": [], + "coverageGapArtifactIds": [ + "co-unknown-owner-absent" + ] + }, + "coverage": [ + { + "artifactId": "co-unknown-owner-absent", + "logicalArtifactId": "client-co-management", + "state": "absent" + } + ], + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "co-unknown-owner-coverage", + "kind": "coverageGap", + "claim": "Ownership remains unknown because the bounded co-management artifact is absent.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "co-unknown-owner-absent" + ] + } + ], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/manifest.json new file mode 100644 index 000000000..bb82087f1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/manifest.json @@ -0,0 +1,39 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "co-management-unknown", + "workflowFamily": "coManagement", + "bundle": { + "bundleId": "sccm-326-co-management-unknown", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "co-unknown-owner-absent", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "absent", + "relativePath": null, + "sanitizedSourcePath": null, + "pathFingerprint": null, + "sourceVersion": null, + "encoding": null, + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..d781fcbfa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-notification/current/CcmNotificationAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-notification/current/CcmNotificationAgent.log new file mode 100644 index 000000000..1aa4b32c7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-notification/current/CcmNotificationAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-scripts/root-a/current/Scripts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-scripts/root-a/current/Scripts.log new file mode 100644 index 000000000..683cda950 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-scripts/root-a/current/Scripts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-scripts/root-b/current/Scripts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-scripts/root-b/current/Scripts.log new file mode 100644 index 000000000..71e242bd5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-scripts/root-b/current/Scripts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/expected.json new file mode 100644 index 000000000..ac72dacb6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/expected.json @@ -0,0 +1,112 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "mixed-unrelated", + "workflow": "mixed", + "extractionProfile": { + "id": "sccm-client-management-mixed-test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "mixedUnknownAndInvalid" + }, + "ownership": { + "classification": "UnknownOwnership", + "confidence": "low", + "terminalHandoff": false, + "workload": "Scripts", + "evidence": [ + { + "artifactId": "mixed-owner-unknown", + "startLine": 1, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "mixed-notification-access", + "logicalArtifactId": "client-notification", + "state": "accessDenied" + }, + { + "artifactId": "mixed-notification-invalid", + "logicalArtifactId": "client-notification", + "state": "captured" + }, + { + "artifactId": "mixed-owner-unknown", + "logicalArtifactId": "client-co-management", + "state": "captured" + }, + { + "artifactId": "mixed-script-root-a", + "logicalArtifactId": "client-scripts", + "state": "captured" + }, + { + "artifactId": "mixed-script-root-b", + "logicalArtifactId": "client-scripts", + "state": "captured" + } + ], + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "mixed-access-gap", + "kind": "coverageGap", + "claim": "Access-denied notification evidence is an explicit coverage state.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "mixed-notification-access" + ] + }, + { + "observationId": "mixed-notification-invalid-offset", + "kind": "invalidOffset", + "claim": "The exact-looking notification record has unusable ordering provenance and stays unlinked.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "mixed-notification-invalid" + ] + }, + { + "observationId": "mixed-owner-invalid-offset", + "kind": "invalidOffset", + "claim": "Conflicting ownership evidence includes an invalid offset and cannot establish ordering.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "mixed-owner-unknown" + ] + }, + { + "observationId": "mixed-owner-unknown-profile", + "kind": "unknownProfile", + "claim": "The unknown source version cannot select a validated ownership profile.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "mixed-owner-unknown" + ] + }, + { + "observationId": "mixed-script-collision", + "kind": "physicalCollision", + "claim": "Same-time same-basename records from different physical roots remain distinct without exact keys.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "mixed-script-root-a", + "mixed-script-root-b" + ] + } + ], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/manifest.json new file mode 100644 index 000000000..6957bdbb0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/manifest.json @@ -0,0 +1,131 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "mixed-unrelated", + "workflowFamily": "mixed", + "bundle": { + "bundleId": "sccm-326-mixed-unrelated", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "mixed-notification-access", + "role": "client", + "logicalArtifactId": "client-notification", + "sourceName": "CcmNotificationAgent.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "accessDenied", + "relativePath": null, + "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/client-notification/access/CcmNotificationAgent.log", + "pathFingerprint": "safe:path:326:mixed-notification-access", + "sourceVersion": null, + "encoding": null, + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "mixed-notification-invalid", + "role": "client", + "logicalArtifactId": "client-notification", + "sourceName": "CcmNotificationAgent.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-notification/current/CcmNotificationAgent.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/client-notification/current/CcmNotificationAgent.log", + "pathFingerprint": "safe:path:326:mixed-notification-invalid", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "mixed-owner-unknown", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:mixed-owner-unknown", + "sourceVersion": "5.99.UNKNOWN.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "mixed-script-root-a", + "role": "client", + "logicalArtifactId": "client-scripts", + "sourceName": "Scripts.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-scripts/root-a/current/Scripts.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/client-scripts/root-a/Scripts.log", + "pathFingerprint": "safe:path:326:mixed-script-root-a", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "mixed-script-root-b", + "role": "client", + "logicalArtifactId": "client-scripts", + "sourceName": "Scripts.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-scripts/root-b/current/Scripts.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/client-scripts/root-b/Scripts.log", + "pathFingerprint": "safe:path:326:mixed-script-root-b", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..8e95d99e3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/evidence/client-notification/current/CcmNotificationAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/evidence/client-notification/current/CcmNotificationAgent.log new file mode 100644 index 000000000..37a27555a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/evidence/client-notification/current/CcmNotificationAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/expected.json new file mode 100644 index 000000000..41c408c2b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/expected.json @@ -0,0 +1,75 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "notification-deferred", + "workflow": "notification", + "extractionProfile": { + "id": "sccm-client-notification-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "SccmOwned", + "confidence": "high", + "terminalHandoff": false, + "workload": "ClientNotification", + "evidence": [ + { + "artifactId": "notification-deferred-owner", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "notification-deferred-current", + "logicalArtifactId": "client-notification", + "state": "captured" + }, + { + "artifactId": "notification-deferred-owner", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [ + { + "transactionId": "notification-deferred-326", + "workflow": "notification", + "key": { + "keyProfileKind": "notificationExact", + "extractionProfileId": "sccm-client-notification-5.00.test-v1", + "confidence": "exact", + "NotificationId": "NOTIFY-326-DEFERRED", + "ChannelId": "CHANNEL-326-CLIENT", + "ResourceHandle": "safe:resource-326-notification-deferred" + }, + "phase": "DeferOrDispatch", + "state": "deferred", + "classification": "blockedOrDeferred", + "confidence": "medium", + "lastSuccessfulPhase": "Receive", + "evidence": [ + { + "artifactId": "notification-deferred-current", + "startLine": 1, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": { + "logicalArtifactId": "client-notification", + "reason": "Collect the bounded client notification continuation for the same exact notification key." + } + } + ], + "sourceLocalObservations": [], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/manifest.json new file mode 100644 index 000000000..7f2a26333 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/manifest.json @@ -0,0 +1,62 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "notification-deferred", + "workflowFamily": "notification", + "bundle": { + "bundleId": "sccm-326-notification-deferred", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "notification-deferred-current", + "role": "client", + "logicalArtifactId": "client-notification", + "sourceName": "CcmNotificationAgent.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-notification/current/CcmNotificationAgent.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/notification-deferred/client-notification/CcmNotificationAgent.log", + "pathFingerprint": "safe:path:326:notification-deferred", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "notification-deferred-owner", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/notification-deferred/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:notification-deferred-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..7f5af2035 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/evidence/client-notification/current/CcmNotificationAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/evidence/client-notification/current/CcmNotificationAgent.log new file mode 100644 index 000000000..f9306ada9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/evidence/client-notification/current/CcmNotificationAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/expected.json new file mode 100644 index 000000000..0443f2ddd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/expected.json @@ -0,0 +1,72 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "notification-failure", + "workflow": "notification", + "extractionProfile": { + "id": "sccm-client-notification-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "SccmOwned", + "confidence": "high", + "terminalHandoff": false, + "workload": "ClientNotification", + "evidence": [ + { + "artifactId": "notification-failure-owner", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "notification-failure-current", + "logicalArtifactId": "client-notification", + "state": "captured" + }, + { + "artifactId": "notification-failure-owner", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [ + { + "transactionId": "notification-failure-326", + "workflow": "notification", + "key": { + "keyProfileKind": "notificationExact", + "extractionProfileId": "sccm-client-notification-5.00.test-v1", + "confidence": "exact", + "NotificationId": "NOTIFY-326-FAILURE", + "ChannelId": "CHANNEL-326-CLIENT", + "ResourceHandle": "safe:resource-326-notification-failure" + }, + "phase": "Acknowledge", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": "Receive", + "evidence": [ + { + "artifactId": "notification-failure-current", + "startLine": 1, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/manifest.json new file mode 100644 index 000000000..4c4aefd3f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/manifest.json @@ -0,0 +1,62 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "notification-failure", + "workflowFamily": "notification", + "bundle": { + "bundleId": "sccm-326-notification-failure", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "notification-failure-current", + "role": "client", + "logicalArtifactId": "client-notification", + "sourceName": "CcmNotificationAgent.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-notification/current/CcmNotificationAgent.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/notification-failure/client-notification/CcmNotificationAgent.log", + "pathFingerprint": "safe:path:326:notification-failure", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "notification-failure-owner", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/notification-failure/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:notification-failure-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..3c856a11e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/evidence/client-notification/current/CcmNotificationAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/evidence/client-notification/current/CcmNotificationAgent.log new file mode 100644 index 000000000..55478498e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/evidence/client-notification/current/CcmNotificationAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/expected.json new file mode 100644 index 000000000..39f19c2d1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/expected.json @@ -0,0 +1,72 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "notification-received", + "workflow": "notification", + "extractionProfile": { + "id": "sccm-client-notification-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "SccmOwned", + "confidence": "high", + "terminalHandoff": false, + "workload": "ClientNotification", + "evidence": [ + { + "artifactId": "notification-received-owner", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "notification-received-current", + "logicalArtifactId": "client-notification", + "state": "captured" + }, + { + "artifactId": "notification-received-owner", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [ + { + "transactionId": "notification-received-326", + "workflow": "notification", + "key": { + "keyProfileKind": "notificationExact", + "extractionProfileId": "sccm-client-notification-5.00.test-v1", + "confidence": "exact", + "NotificationId": "NOTIFY-326-RECEIVED", + "ChannelId": "CHANNEL-326-CLIENT", + "ResourceHandle": "safe:resource-326-notification-received" + }, + "phase": "Acknowledge", + "state": "acknowledged", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Acknowledge", + "evidence": [ + { + "artifactId": "notification-received-current", + "startLine": 1, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/manifest.json new file mode 100644 index 000000000..aca9a2b72 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/manifest.json @@ -0,0 +1,62 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "notification-received", + "workflowFamily": "notification", + "bundle": { + "bundleId": "sccm-326-notification-received", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "notification-received-current", + "role": "client", + "logicalArtifactId": "client-notification", + "sourceName": "CcmNotificationAgent.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-notification/current/CcmNotificationAgent.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/notification-received/client-notification/CcmNotificationAgent.log", + "pathFingerprint": "safe:path:326:notification-received", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "notification-received-owner", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/notification-received/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:notification-received-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..f85d6eeb8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/evidence/client-scripts/current/Scripts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/evidence/client-scripts/current/Scripts.log new file mode 100644 index 000000000..465b95a57 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/evidence/client-scripts/current/Scripts.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/expected.json new file mode 100644 index 000000000..02493a4d6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/expected.json @@ -0,0 +1,72 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "script-failure", + "workflow": "scripts", + "extractionProfile": { + "id": "sccm-client-scripts-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "SccmOwned", + "confidence": "high", + "terminalHandoff": false, + "workload": "Scripts", + "evidence": [ + { + "artifactId": "script-failure-owner", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "script-failure-current", + "logicalArtifactId": "client-scripts", + "state": "captured" + }, + { + "artifactId": "script-failure-owner", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [ + { + "transactionId": "script-failure-exec-326", + "workflow": "scripts", + "key": { + "keyProfileKind": "scriptExact", + "extractionProfileId": "sccm-client-scripts-5.00.test-v1", + "confidence": "exact", + "ScriptId": "SCRIPT-326-FAILURE", + "ExecutionId": "EXEC-326-FAILURE", + "ResourceHandle": "safe:resource-326-script-failure" + }, + "phase": "Execute", + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "lastSuccessfulPhase": "Receive", + "evidence": [ + { + "artifactId": "script-failure-current", + "startLine": 1, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/manifest.json new file mode 100644 index 000000000..c7b8f40ec --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/manifest.json @@ -0,0 +1,62 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "script-failure", + "workflowFamily": "scripts", + "bundle": { + "bundleId": "sccm-326-script-failure", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "script-failure-current", + "role": "client", + "logicalArtifactId": "client-scripts", + "sourceName": "Scripts.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-scripts/current/Scripts.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/script-failure/client-scripts/Scripts.log", + "pathFingerprint": "safe:path:326:script-failure", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "script-failure-owner", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/script-failure/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:script-failure-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..5d5419be2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-scripts/current/Scripts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-scripts/current/Scripts.log new file mode 100644 index 000000000..09617b5c6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-scripts/current/Scripts.log @@ -0,0 +1 @@ +SYNTHETIC FIXTURE EXEC-326-INCOMPLETE ResourceHandle=safe:resource-326-script-incomplete Phase=Report Disposition=Succeeded Terminal=true]LOG]!> diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-scripts/lo/Scripts.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-scripts/lo/Scripts.lo_ new file mode 100644 index 000000000..84f8b1e43 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-scripts/lo/Scripts.lo_ @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/evidence/client-scripts/current/Scripts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/evidence/client-scripts/current/Scripts.log new file mode 100644 index 000000000..70b0671b2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/evidence/client-scripts/current/Scripts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/expected.json new file mode 100644 index 000000000..5a0dc489c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/expected.json @@ -0,0 +1,56 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "script-intune-handoff", + "workflow": "scripts", + "extractionProfile": { + "id": "sccm-client-scripts-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "IntuneOwned", + "confidence": "high", + "terminalHandoff": true, + "workload": "Scripts", + "evidence": [ + { + "artifactId": "script-intune-owner", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "script-intune-error-current", + "logicalArtifactId": "client-scripts", + "state": "captured" + }, + { + "artifactId": "script-intune-owner", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "script-intune-unrelated-error", + "kind": "unkeyedRecord", + "claim": "An unkeyed SCCM error remains source-local after an evidenced Intune handoff.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "script-intune-error-current" + ] + } + ], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/manifest.json new file mode 100644 index 000000000..aa73e2664 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/manifest.json @@ -0,0 +1,62 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "script-intune-handoff", + "workflowFamily": "scripts", + "bundle": { + "bundleId": "sccm-326-script-intune-handoff", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "script-intune-error-current", + "role": "client", + "logicalArtifactId": "client-scripts", + "sourceName": "Scripts.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-scripts/current/Scripts.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/script-intune-handoff/client-scripts/Scripts.log", + "pathFingerprint": "safe:path:326:script-intune-error", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "script-intune-owner", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/script-intune-handoff/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:script-intune-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..f88e615e1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/evidence/client-scripts/current/Scripts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/evidence/client-scripts/current/Scripts.log new file mode 100644 index 000000000..af87f38a1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/evidence/client-scripts/current/Scripts.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/expected.json new file mode 100644 index 000000000..4eb5f068c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/expected.json @@ -0,0 +1,72 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "script-success", + "workflow": "scripts", + "extractionProfile": { + "id": "sccm-client-scripts-5.00.test-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "selected" + }, + "ownership": { + "classification": "SccmOwned", + "confidence": "high", + "terminalHandoff": false, + "workload": "Scripts", + "evidence": [ + { + "artifactId": "script-success-owner", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "script-success-current", + "logicalArtifactId": "client-scripts", + "state": "captured" + }, + { + "artifactId": "script-success-owner", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [ + { + "transactionId": "script-success-exec-326", + "workflow": "scripts", + "key": { + "keyProfileKind": "scriptExact", + "extractionProfileId": "sccm-client-scripts-5.00.test-v1", + "confidence": "exact", + "ScriptId": "SCRIPT-326-SUCCESS", + "ExecutionId": "EXEC-326-SUCCESS", + "ResourceHandle": "safe:resource-326-script-success" + }, + "phase": "Report", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "script-success-current", + "startLine": 1, + "endLine": 3 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + } + ], + "sourceLocalObservations": [], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/manifest.json new file mode 100644 index 000000000..31afadd8b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/manifest.json @@ -0,0 +1,62 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "script-success", + "workflowFamily": "scripts", + "bundle": { + "bundleId": "sccm-326-script-success", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "script-success-current", + "role": "client", + "logicalArtifactId": "client-scripts", + "sourceName": "Scripts.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-scripts/current/Scripts.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/script-success/client-scripts/Scripts.log", + "pathFingerprint": "safe:path:326:script-success", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "script-success-owner", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/script-success/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:script-success-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/evidence/client-software-center/current/SCClient_SYNTHETIC_2.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/evidence/client-software-center/current/SCClient_SYNTHETIC_2.log new file mode 100644 index 000000000..e8b4c069b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/evidence/client-software-center/current/SCClient_SYNTHETIC_2.log @@ -0,0 +1 @@ +SYNTHETIC FIXTURE malformed Software Center candidate with no complete CCM record. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/expected.json new file mode 100644 index 000000000..1febb0e77 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/expected.json @@ -0,0 +1,92 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "software-center-insufficient", + "workflow": "softwareCenter", + "extractionProfile": { + "id": "sccm-client-software-center-candidate-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "unsupportedCandidate" + }, + "ownership": { + "classification": "UnknownOwnership", + "confidence": "low", + "terminalHandoff": false, + "workload": "SoftwareCenter", + "evidence": [], + "coverageGapArtifactIds": [ + "software-center-insufficient-owner" + ] + }, + "coverage": [ + { + "artifactId": "software-center-insufficient-absent", + "logicalArtifactId": "client-software-center", + "state": "absent" + }, + { + "artifactId": "software-center-insufficient-malformed", + "logicalArtifactId": "client-software-center", + "state": "malformed" + }, + { + "artifactId": "software-center-insufficient-owner", + "logicalArtifactId": "client-co-management", + "state": "absent" + }, + { + "artifactId": "software-center-insufficient-unsupported", + "logicalArtifactId": "client-software-center", + "state": "unsupported" + } + ], + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "software-center-insufficient-absent-gap", + "kind": "coverageGap", + "claim": "The bounded Software Center candidate was absent; absence is not an outcome.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "software-center-insufficient-absent" + ] + }, + { + "observationId": "software-center-insufficient-malformed-gap", + "kind": "malformedRecord", + "claim": "Malformed candidate bytes cannot establish a Software Center action.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "software-center-insufficient-malformed" + ] + }, + { + "observationId": "software-center-insufficient-owner-gap", + "kind": "coverageGap", + "claim": "Ownership remains unknown because co-management evidence is absent.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "software-center-insufficient-owner" + ] + }, + { + "observationId": "software-center-insufficient-unsupported-gap", + "kind": "unsupportedCandidate", + "claim": "An unsupported notification candidate is not admitted as Software Center evidence.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "software-center-insufficient-unsupported" + ] + } + ], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/manifest.json new file mode 100644 index 000000000..90806846b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/manifest.json @@ -0,0 +1,108 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "software-center-insufficient", + "workflowFamily": "softwareCenter", + "bundle": { + "bundleId": "sccm-326-software-center-insufficient", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "software-center-insufficient-absent", + "role": "client", + "logicalArtifactId": "client-software-center", + "sourceName": "SCClient_SYNTHETIC_1.log", + "catalogState": "candidateUnsupported", + "parserEligible": false, + "captureState": "absent", + "relativePath": null, + "sanitizedSourcePath": null, + "pathFingerprint": null, + "sourceVersion": null, + "encoding": null, + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "software-center-insufficient-malformed", + "role": "client", + "logicalArtifactId": "client-software-center", + "sourceName": "SCClient_SYNTHETIC_2.log", + "catalogState": "candidateUnsupported", + "parserEligible": false, + "captureState": "parseFailed", + "relativePath": "evidence/client-software-center/current/SCClient_SYNTHETIC_2.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/software-center-insufficient/client-software-center/SCClient_SYNTHETIC_2.log", + "pathFingerprint": "safe:path:326:software-center-malformed", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "software-center-insufficient-owner", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "absent", + "relativePath": null, + "sanitizedSourcePath": null, + "pathFingerprint": null, + "sourceVersion": null, + "encoding": null, + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "software-center-insufficient-unsupported", + "role": "client", + "logicalArtifactId": "client-software-center", + "sourceName": "SCNotify_SYNTHETIC_1.log", + "catalogState": "candidateUnsupported", + "parserEligible": false, + "captureState": "unsupported", + "relativePath": null, + "sanitizedSourcePath": "SYNTHETIC://client/management/software-center-insufficient/client-software-center/SCNotify_SYNTHETIC_1.log", + "pathFingerprint": "safe:path:326:software-center-unsupported", + "sourceVersion": null, + "encoding": null, + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/evidence/client-co-management/current/CoManagementHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/evidence/client-co-management/current/CoManagementHandler.log new file mode 100644 index 000000000..0ffa45f1a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/evidence/client-co-management/current/CoManagementHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/evidence/client-software-center/current/SCClient_SYNTHETIC_1.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/evidence/client-software-center/current/SCClient_SYNTHETIC_1.log new file mode 100644 index 000000000..361aefe3c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/evidence/client-software-center/current/SCClient_SYNTHETIC_1.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/expected.json new file mode 100644 index 000000000..a1fd74701 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/expected.json @@ -0,0 +1,56 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "software-center-observed", + "workflow": "softwareCenter", + "extractionProfile": { + "id": "sccm-client-software-center-candidate-v1", + "versionPrefix": "5.00.TEST.", + "selectionState": "unsupportedCandidate" + }, + "ownership": { + "classification": "SccmOwned", + "confidence": "high", + "terminalHandoff": false, + "workload": "SoftwareCenter", + "evidence": [ + { + "artifactId": "software-center-observed-owner", + "startLine": 1, + "endLine": 1 + } + ], + "coverageGapArtifactIds": [] + }, + "coverage": [ + { + "artifactId": "software-center-observed-candidate", + "logicalArtifactId": "client-software-center", + "state": "unsupported" + }, + { + "artifactId": "software-center-observed-owner", + "logicalArtifactId": "client-co-management", + "state": "captured" + } + ], + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "software-center-observed-unsupported", + "kind": "unsupportedCandidate", + "claim": "The captured Software Center candidate remains observational and parser-ineligible.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [ + "software-center-observed-candidate" + ] + } + ], + "findings": [], + "prohibitedClaims": [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/manifest.json new file mode 100644 index 000000000..b980925dc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/manifest.json @@ -0,0 +1,62 @@ +{ + "sccmManifestVersion": 1, + "contractState": "proposedPending318And319", + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "software-center-observed", + "workflowFamily": "softwareCenter", + "bundle": { + "bundleId": "sccm-326-software-center-observed", + "captureHost": "LAB-CLIENT-01", + "role": "client", + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "software-center-observed-candidate", + "role": "client", + "logicalArtifactId": "client-software-center", + "sourceName": "SCClient_SYNTHETIC_1.log", + "catalogState": "candidateUnsupported", + "parserEligible": false, + "captureState": "captured", + "relativePath": "evidence/client-software-center/current/SCClient_SYNTHETIC_1.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/software-center-observed/client-software-center/SCClient_SYNTHETIC_1.log", + "pathFingerprint": "safe:path:326:software-center-observed", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + }, + { + "artifactId": "software-center-observed-owner", + "role": "client", + "logicalArtifactId": "client-co-management", + "sourceName": "CoManagementHandler.log", + "catalogState": "admitted", + "parserEligible": true, + "captureState": "captured", + "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/software-center-observed/client-co-management/CoManagementHandler.log", + "pathFingerprint": "safe:path:326:software-center-observed-owner", + "sourceVersion": "5.00.TEST.3260", + "encoding": "utf-8", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + }, + "capturedUtc": "2026-07-31T00:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..669920e2a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-agent/current/Scheduler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-agent/current/Scheduler.log new file mode 100644 index 000000000..ce479fab9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-agent/current/Scheduler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-state/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-state/current/CIAgent.log new file mode 100644 index 000000000..b9325b1c0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-state/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-state/current/StateMessage.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-state/current/StateMessage.log new file mode 100644 index 000000000..94cf17ed4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-state/current/StateMessage.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/expected.json new file mode 100644 index 000000000..825989875 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/expected.json @@ -0,0 +1,39 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "complete", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent","client-policy-state"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"},{"logicalArtifactId":"client-policy-state","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"policy-complete-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-complete-scheduler-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-complete-evaluate-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-complete-report-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [{ + "transactionId": "policy:assignment:11111111-1111-1111-1111-111111111111", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"11111111-1111-1111-1111-111111111111","policyId":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","requestId":"27111111-1111-1111-1111-111111111111","clientHandle":"safe:client:policy-11","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-complete-agent-current","startLine":1,"endLine":1}}, + "phase": "report", + "state": "succeeded", + "lastSuccessfulPhase": "report", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"policy-complete-agent-current","startLine":1,"endLine":3}, + {"artifactId":"policy-complete-evaluate-current","startLine":1,"endLine":1}, + {"artifactId":"policy-complete-report-current","startLine":1,"endLine":1}, + {"artifactId":"policy-complete-scheduler-current","startLine":1,"endLine":1} + ] + }], + "sourceLocalObservations": [], + "findings": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/manifest.json new file mode 100644 index 000000000..5e9564432 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-complete-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-complete-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:10Z","bytesCopied":938,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"policy-complete-scheduler-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"Scheduler.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/Scheduler.log","pathFingerprint":"synthetic:policy-complete-scheduler","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:10Z","bytesCopied":285,"relativePath":"evidence/client-policy-agent/current/Scheduler.log"}, + {"artifactId":"policy-complete-evaluate-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic:policy-complete-evaluate","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:10Z","bytesCopied":283,"relativePath":"evidence/client-policy-state/current/CIAgent.log"}, + {"artifactId":"policy-complete-report-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"StateMessage.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/StateMessage.log","pathFingerprint":"synthetic:policy-complete-report","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:10Z","bytesCopied":286,"relativePath":"evidence/client-policy-state/current/StateMessage.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..dee4c6a94 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-agent/current/Scheduler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-agent/current/Scheduler.log new file mode 100644 index 000000000..9942e332c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-agent/current/Scheduler.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/current/StateMessage.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/current/StateMessage.log new file mode 100644 index 000000000..1e9e9ccef --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/current/StateMessage.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/root-a/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/root-a/current/CIAgent.log new file mode 100644 index 000000000..e3b0858ac --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/root-a/current/CIAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/root-b/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/root-b/current/CIAgent.log new file mode 100644 index 000000000..7155fe766 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/root-b/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/expected.json new file mode 100644 index 000000000..38e9ce3a6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/expected.json @@ -0,0 +1,95 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "contradictory-offset", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent","client-policy-state"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"},{"logicalArtifactId":"client-policy-state","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"policy-offset-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-offset-scheduler-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-offset-evaluate-invalid","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-offset-evaluate-valid","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-offset-report-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [ + { + "transactionId": "policy:assignment:25252525-2525-2525-2525-252525252525", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"25252525-2525-2525-2525-252525252525","policyId":"b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7","requestId":"27252525-2525-2525-2525-252525252525","clientHandle":"safe:client:policy-25","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-offset-agent-current","startLine":1,"endLine":1}}, + "phase": "report", + "state": "succeeded", + "lastSuccessfulPhase": "report", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"policy-offset-agent-current","startLine":1,"endLine":3}, + {"artifactId":"policy-offset-evaluate-valid","startLine":1,"endLine":1}, + {"artifactId":"policy-offset-report-current","startLine":1,"endLine":1}, + {"artifactId":"policy-offset-scheduler-current","startLine":1,"endLine":1} + ] + }, + { + "transactionId": "policy:assignment:26262626-2626-2626-2626-262626262626", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"26262626-2626-2626-2626-262626262626","policyId":"b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8","requestId":"27262626-2626-2626-2626-262626262626","clientHandle":"safe:client:policy-26","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-offset-agent-current","startLine":4,"endLine":4}}, + "phase": "evaluate", + "state": "contradictory", + "lastSuccessfulPhase": "schedule", + "classification": "contradictoryEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-policy-state","reason":"Recapture bounded policy-state evidence with a valid timestamp offset; do not order by display time."}, + "evidence": [ + {"artifactId":"policy-offset-agent-current","startLine":4,"endLine":6}, + {"artifactId":"policy-offset-evaluate-invalid","startLine":1,"endLine":1}, + {"artifactId":"policy-offset-evaluate-valid","startLine":2,"endLine":2}, + {"artifactId":"policy-offset-scheduler-current","startLine":2,"endLine":2} + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:policy-offset-noncomparable", + "subjectId": "policy:assignment:26262626-2626-2626-2626-262626262626", + "class": "contradictoryEvidence", + "phase": "evaluate", + "lastSuccessfulPhase": "schedule", + "confidence": "low", + "confidenceCeiling": "low", + "nextArtifact": {"logicalArtifactId":"client-policy-state","reason":"Recapture bounded policy-state evidence with a valid timestamp offset; do not order by display time."}, + "evidence": [ + {"artifactId":"policy-offset-evaluate-invalid","startLine":1,"endLine":1}, + {"artifactId":"policy-offset-evaluate-valid","startLine":2,"endLine":2} + ] + } + ], + "offsetOrderingContract": { + "validOffsetsNormalizeToUtc": true, + "originalDisplayAndOffsetPreserved": true, + "invalidOffsetsAreNonComparable": true, + "displayTimeCoincidenceRaisesConfidence": false, + "sameDisplayMinuteDifferentKeysRemainSeparate": true, + "timeOnlyCausalityProhibited": true, + "orderedEvidence": [ + {"artifactId":"policy-offset-agent-current","startLine":1,"endLine":1,"originalDisplay":"7-30-2026 15:00:00.000+060","originalOffset":"+060","normalizedUtc":"2026-07-30T14:00:00.000Z"}, + {"artifactId":"policy-offset-agent-current","startLine":2,"endLine":2,"originalDisplay":"7-30-2026 14:01:00.000+000","originalOffset":"+000","normalizedUtc":"2026-07-30T14:01:00.000Z"} + ], + "nonComparableEvidence": [ + {"artifactId":"policy-offset-evaluate-invalid","startLine":1,"endLine":1,"originalDisplay":"7-30-2026 15:04:00.000+9999","originalOffset":"+9999","normalizedUtc":null,"comparable":false,"confidenceCeiling":"low"} + ], + "sameDisplayTimeEvidence": [ + {"artifactId":"policy-offset-agent-current","startLine":1,"endLine":1}, + {"artifactId":"policy-offset-agent-current","startLine":4,"endLine":4} + ] + }, + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/manifest.json new file mode 100644 index 000000000..d9579f13d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/manifest.json @@ -0,0 +1,18 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "orderingTopology": { + "independentArtifactSets": [ + {"setId":"offset-invalid-evaluate-tie","artifactIds":["policy-offset-evaluate-invalid","policy-offset-evaluate-valid"],"validatedProfileId":"policy-client-5.00.test-v1","role":"client","captureHost":"LAB-CLIENT-01","sourceLocalOrder":false,"lineageOrder":false} + ] + }, + "artifacts": [ + {"artifactId":"policy-offset-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-offset-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T16:00:10Z","bytesCopied":1894,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"policy-offset-scheduler-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"Scheduler.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/Scheduler.log","pathFingerprint":"synthetic:policy-offset-scheduler","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T16:00:10Z","bytesCopied":588,"relativePath":"evidence/client-policy-agent/current/Scheduler.log"}, + {"artifactId":"policy-offset-evaluate-invalid","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-b/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic:policy-offset-evaluate-invalid","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T16:00:10Z","bytesCopied":317,"relativePath":"evidence/client-policy-state/root-b/current/CIAgent.log"}, + {"artifactId":"policy-offset-evaluate-valid","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic:policy-offset-evaluate-valid","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T16:00:10Z","bytesCopied":540,"relativePath":"evidence/client-policy-state/root-a/current/CIAgent.log"}, + {"artifactId":"policy-offset-report-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"StateMessage.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/StateMessage.log","pathFingerprint":"synthetic:policy-offset-report","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T16:00:10Z","bytesCopied":295,"relativePath":"evidence/client-policy-state/current/StateMessage.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..256610d12 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/expected.json new file mode 100644 index 000000000..b32276313 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/expected.json @@ -0,0 +1,39 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "download-failure", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"}], + "artifactProvenance": [{"artifactId":"policy-download-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}], + "transactions": [{ + "transactionId": "policy:assignment:13131313-1313-1313-1313-131313131313", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"13131313-1313-1313-1313-131313131313","policyId":"acacacac-acac-acac-acac-acacacacacac","requestId":"27131313-1313-1313-1313-131313131313","clientHandle":"safe:client:policy-13","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-download-agent-current","startLine":1,"endLine":1}}, + "phase": "download", + "state": "failed", + "lastSuccessfulPhase": "request", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [{"artifactId":"policy-download-agent-current","startLine":1,"endLine":2}] + }], + "sourceLocalObservations": [], + "findings": [{ + "findingId": "finding:policy-download-failure", + "subjectId": "policy:assignment:13131313-1313-1313-1313-131313131313", + "class": "confirmedFailure", + "phase": "download", + "lastSuccessfulPhase": "request", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [{"artifactId":"policy-download-agent-current","startLine":2,"endLine":2}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/manifest.json new file mode 100644 index 000000000..41a6de539 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/manifest.json @@ -0,0 +1,9 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-download-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-download-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T03:00:02Z","bytesCopied":719,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..8dd24971d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-agent/current/Scheduler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-agent/current/Scheduler.log new file mode 100644 index 000000000..85aa4c171 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-agent/current/Scheduler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-state/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-state/current/CIAgent.log new file mode 100644 index 000000000..af7dc0978 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-state/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/expected.json new file mode 100644 index 000000000..66d14cf9c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/expected.json @@ -0,0 +1,47 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "evaluation-failure", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent","client-policy-state"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"},{"logicalArtifactId":"client-policy-state","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"policy-evaluation-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-evaluation-scheduler-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-evaluation-state-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [{ + "transactionId": "policy:assignment:16161616-1616-1616-1616-161616161616", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"16161616-1616-1616-1616-161616161616","policyId":"afafafaf-afaf-afaf-afaf-afafafafafaf","requestId":"27161616-1616-1616-1616-161616161616","clientHandle":"safe:client:policy-16","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-evaluation-agent-current","startLine":1,"endLine":1}}, + "phase": "evaluate", + "state": "failed", + "lastSuccessfulPhase": "schedule", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"policy-evaluation-agent-current","startLine":1,"endLine":3}, + {"artifactId":"policy-evaluation-scheduler-current","startLine":1,"endLine":1}, + {"artifactId":"policy-evaluation-state-current","startLine":1,"endLine":1} + ] + }], + "sourceLocalObservations": [], + "findings": [{ + "findingId": "finding:policy-evaluation-failure", + "subjectId": "policy:assignment:16161616-1616-1616-1616-161616161616", + "class": "confirmedFailure", + "phase": "evaluate", + "lastSuccessfulPhase": "schedule", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [{"artifactId":"policy-evaluation-state-current","startLine":1,"endLine":1}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/manifest.json new file mode 100644 index 000000000..c917a9927 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-evaluation-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-evaluation-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T06:00:05Z","bytesCopied":948,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"policy-evaluation-scheduler-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"Scheduler.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/Scheduler.log","pathFingerprint":"synthetic:policy-evaluation-scheduler","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T06:00:05Z","bytesCopied":295,"relativePath":"evidence/client-policy-agent/current/Scheduler.log"}, + {"artifactId":"policy-evaluation-state-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic:policy-evaluation-state","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T06:00:05Z","bytesCopied":317,"relativePath":"evidence/client-policy-state/current/CIAgent.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..190a5c775 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-agent/current/Scheduler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-agent/current/Scheduler.log new file mode 100644 index 000000000..ee78cd9f4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-agent/current/Scheduler.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/current/StateMessage.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/current/StateMessage.log new file mode 100644 index 000000000..f2d48b812 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/current/StateMessage.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/root-a/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/root-a/current/CIAgent.log new file mode 100644 index 000000000..4a0c724ee --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/root-a/current/CIAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/root-b/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/root-b/current/CIAgent.log new file mode 100644 index 000000000..c847bd930 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/root-b/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/expected.json new file mode 100644 index 000000000..3304ac4cb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/expected.json @@ -0,0 +1,98 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "gate-c-contradictory", + "gate": "C", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent","client-policy-state"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"},{"logicalArtifactId":"client-policy-state","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"policy-gate-c-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-gate-c-scheduler-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-gate-c-evaluate-failure","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-gate-c-evaluate-success","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-gate-c-report-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [ + { + "transactionId": "policy:assignment:19191919-1919-1919-1919-191919191919", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"19191919-1919-1919-1919-191919191919","policyId":"b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2","requestId":"27191919-1919-1919-1919-191919191919","clientHandle":"safe:client:policy-19","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-gate-c-agent-current","startLine":1,"endLine":1}}, + "phase": "evaluate", + "state": "contradictory", + "lastSuccessfulPhase": "schedule", + "classification": "contradictoryEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-policy-state","reason":"Recapture bounded CIAgent evidence to resolve the same-instant evaluation contradiction."}, + "evidence": [ + {"artifactId":"policy-gate-c-agent-current","startLine":1,"endLine":3}, + {"artifactId":"policy-gate-c-evaluate-failure","startLine":1,"endLine":1}, + {"artifactId":"policy-gate-c-evaluate-success","startLine":1,"endLine":1}, + {"artifactId":"policy-gate-c-scheduler-current","startLine":1,"endLine":1} + ] + }, + { + "transactionId": "policy:assignment:20202020-2020-2020-2020-202020202020", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"20202020-2020-2020-2020-202020202020","policyId":"b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3","requestId":"27202020-2020-2020-2020-202020202020","clientHandle":"safe:client:policy-20","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-gate-c-agent-current","startLine":4,"endLine":4}}, + "phase": "report", + "state": "failed", + "lastSuccessfulPhase": "evaluate", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"policy-gate-c-agent-current","startLine":4,"endLine":6}, + {"artifactId":"policy-gate-c-evaluate-success","startLine":2,"endLine":2}, + {"artifactId":"policy-gate-c-report-current","startLine":1,"endLine":1}, + {"artifactId":"policy-gate-c-scheduler-current","startLine":2,"endLine":2} + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:gate-c-a-contradictory", + "subjectId": "policy:assignment:19191919-1919-1919-1919-191919191919", + "class": "contradictoryEvidence", + "phase": "evaluate", + "lastSuccessfulPhase": "schedule", + "confidence": "low", + "confidenceCeiling": "low", + "nextArtifact": {"logicalArtifactId":"client-policy-state","reason":"Recapture bounded CIAgent evidence to resolve the same-instant evaluation contradiction."}, + "evidence": [ + {"artifactId":"policy-gate-c-evaluate-failure","startLine":1,"endLine":1}, + {"artifactId":"policy-gate-c-evaluate-success","startLine":1,"endLine":1} + ] + }, + { + "findingId": "finding:gate-c-b-reporting-failure", + "subjectId": "policy:assignment:20202020-2020-2020-2020-202020202020", + "class": "confirmedFailure", + "phase": "report", + "lastSuccessfulPhase": "evaluate", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [{"artifactId":"policy-gate-c-report-current","startLine":1,"endLine":1}] + } + ], + "contradictionContract": { + "sameTimestampSameKeyRemainsContradictory": true, + "sameNormalizedInstantSameKeyIndependentArtifactsRemainContradictory": true, + "sameValidatedProfileAndTopology": true, + "sameMinuteDifferentKeysRemainSeparate": true, + "sourceLocalOrderAvailable": false, + "lineageOrderAvailable": false, + "inputOrderCannotResolveCrossArtifactTie": true, + "timeOnlyCausalityProhibited": true + }, + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/manifest.json new file mode 100644 index 000000000..a8e072448 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/manifest.json @@ -0,0 +1,18 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "orderingTopology": { + "independentArtifactSets": [ + {"setId":"gate-c-evaluate-tie","artifactIds":["policy-gate-c-evaluate-failure","policy-gate-c-evaluate-success"],"validatedProfileId":"policy-client-5.00.test-v1","role":"client","captureHost":"LAB-CLIENT-01","sourceLocalOrder":false,"lineageOrder":false} + ] + }, + "artifacts": [ + {"artifactId":"policy-gate-c-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-gate-c-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T11:00:06Z","bytesCopied":1839,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"policy-gate-c-scheduler-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"Scheduler.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/Scheduler.log","pathFingerprint":"synthetic:policy-gate-c-scheduler","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T11:00:06Z","bytesCopied":533,"relativePath":"evidence/client-policy-agent/current/Scheduler.log"}, + {"artifactId":"policy-gate-c-evaluate-failure","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-b/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic:policy-gate-c-evaluate-failure","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T11:00:06Z","bytesCopied":314,"relativePath":"evidence/client-policy-state/root-b/current/CIAgent.log"}, + {"artifactId":"policy-gate-c-evaluate-success","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic:policy-gate-c-evaluate-success","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T11:00:06Z","bytesCopied":538,"relativePath":"evidence/client-policy-state/root-a/current/CIAgent.log"}, + {"artifactId":"policy-gate-c-report-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"StateMessage.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/StateMessage.log","pathFingerprint":"synthetic:policy-gate-c-report","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T11:00:06Z","bytesCopied":308,"relativePath":"evidence/client-policy-state/current/StateMessage.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..3ac4e1381 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/evidence/client-policy-agent/current/Scheduler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/evidence/client-policy-agent/current/Scheduler.log new file mode 100644 index 000000000..64ac61504 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/evidence/client-policy-agent/current/Scheduler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/expected.json new file mode 100644 index 000000000..38346d47c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/expected.json @@ -0,0 +1,45 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "incomplete", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent","client-policy-state"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"},{"logicalArtifactId":"client-policy-state","state":"absent"}], + "artifactProvenance": [ + {"artifactId":"policy-incomplete-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-incomplete-scheduler-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [{ + "transactionId": "policy:assignment:18181818-1818-1818-1818-181818181818", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"18181818-1818-1818-1818-181818181818","policyId":"b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1","requestId":"27181818-1818-1818-1818-181818181818","clientHandle":"safe:client:policy-18","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-incomplete-agent-current","startLine":1,"endLine":1}}, + "phase": "schedule", + "state": "incomplete", + "lastSuccessfulPhase": "schedule", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "coverageGapArtifactIds": ["client-policy-state"], + "nextArtifact": {"logicalArtifactId":"client-policy-state","reason":"Capture bounded CIAgent or StateMessage evidence for Evaluate and Report."}, + "evidence": [ + {"artifactId":"policy-incomplete-agent-current","startLine":1,"endLine":3}, + {"artifactId":"policy-incomplete-scheduler-current","startLine":1,"endLine":1} + ] + }], + "sourceLocalObservations": [], + "findings": [{ + "findingId": "finding:policy-incomplete-state", + "subjectId": "policy:assignment:18181818-1818-1818-1818-181818181818", + "class": "insufficientEvidence", + "phase": "schedule", + "lastSuccessfulPhase": "schedule", + "confidence": "medium", + "confidenceCeiling": "medium", + "nextArtifact": {"logicalArtifactId":"client-policy-state","reason":"Capture bounded CIAgent or StateMessage evidence for Evaluate and Report."}, + "evidence": [{"artifactId":"policy-incomplete-scheduler-current","startLine":1,"endLine":1}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/manifest.json new file mode 100644 index 000000000..a96d40777 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-incomplete-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-incomplete-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T10:00:04Z","bytesCopied":940,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"policy-incomplete-scheduler-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"Scheduler.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/Scheduler.log","pathFingerprint":"synthetic:policy-incomplete-scheduler","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T10:00:04Z","bytesCopied":287,"relativePath":"evidence/client-policy-agent/current/Scheduler.log"}, + {"artifactId":"policy-incomplete-state-absent","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"CIAgent.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T10:00:05Z","bytesCopied":0,"relativePath":null} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..f71fab73c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/expected.json new file mode 100644 index 000000000..8b3ba2134 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/expected.json @@ -0,0 +1,39 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "malformed", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"unvalidatedVersion","profileId":null,"sourceVersionPrefix":null,"keyKinds":[],"validatedArtifactFamilies":[]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"}], + "artifactProvenance": [{"artifactId":"policy-malformed-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}], + "transactions": [], + "sourceLocalObservations": [{ + "observationId": "policy:source-local:malformed", + "key": null, + "keyConfidence": "none", + "phase": null, + "state": "observed", + "classification": "lowConfidenceSymptom", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "lastSuccessfulPhase": null, + "nextArtifact": {"logicalArtifactId":"client-policy-agent","reason":"Capture bounded policy-agent evidence under a validated ConfigMgr version profile with a complete exact key."}, + "evidence": [{"artifactId":"policy-malformed-agent-current","startLine":1,"endLine":1}] + }], + "findings": [{ + "findingId": "finding:policy-malformed", + "subjectId": "policy:source-local:malformed", + "class": "lowConfidenceSymptom", + "phase": null, + "lastSuccessfulPhase": null, + "confidence": "low", + "confidenceCeiling": "low", + "nextArtifact": {"logicalArtifactId":"client-policy-agent","reason":"Capture bounded policy-agent evidence under a validated ConfigMgr version profile with a complete exact key."}, + "evidence": [{"artifactId":"policy-malformed-agent-current","startLine":1,"endLine":1}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":false}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/manifest.json new file mode 100644 index 000000000..2ac354021 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/manifest.json @@ -0,0 +1,9 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-malformed-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-malformed-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.UNKNOWN.0000","capturedUtc":"2026-07-30T09:00:01Z","bytesCopied":248,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..ce024a85b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,4 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-agent/current/Scheduler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-agent/current/Scheduler.log new file mode 100644 index 000000000..9acb965ab --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-agent/current/Scheduler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-state/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-state/current/CIAgent.log new file mode 100644 index 000000000..3887549e5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-state/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-state/current/StateMessage.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-state/current/StateMessage.log new file mode 100644 index 000000000..f3c5c032d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-state/current/StateMessage.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/expected.json new file mode 100644 index 000000000..9d5ef05d5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/expected.json @@ -0,0 +1,45 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "multiline", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent","client-policy-state"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"},{"logicalArtifactId":"client-policy-state","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"policy-multiline-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-multiline-scheduler-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-multiline-evaluate-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-multiline-report-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [{ + "transactionId": "policy:assignment:24242424-2424-2424-2424-242424242424", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"24242424-2424-2424-2424-242424242424","policyId":"b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6","requestId":"27242424-2424-2424-2424-242424242424","clientHandle":"safe:client:policy-24","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-multiline-agent-current","startLine":1,"endLine":2}}, + "phase": "report", + "state": "succeeded", + "lastSuccessfulPhase": "report", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"policy-multiline-agent-current","startLine":1,"endLine":4}, + {"artifactId":"policy-multiline-evaluate-current","startLine":1,"endLine":1}, + {"artifactId":"policy-multiline-report-current","startLine":1,"endLine":1}, + {"artifactId":"policy-multiline-scheduler-current","startLine":1,"endLine":1} + ] + }], + "sourceLocalObservations": [], + "findings": [], + "multilineContract": { + "oneLogicalRecordAcrossPhysicalLines": true, + "logicalRecordEvidence": {"artifactId":"policy-multiline-agent-current","startLine":1,"endLine":2}, + "logicalRecordCount": 1, + "physicalLineCount": 2 + }, + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/manifest.json new file mode 100644 index 000000000..4ddfbfe92 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-multiline-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-multiline-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T13:00:10Z","bytesCopied":939,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"policy-multiline-scheduler-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"Scheduler.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/Scheduler.log","pathFingerprint":"synthetic:policy-multiline-scheduler","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T13:00:10Z","bytesCopied":286,"relativePath":"evidence/client-policy-agent/current/Scheduler.log"}, + {"artifactId":"policy-multiline-evaluate-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic:policy-multiline-evaluate","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T13:00:10Z","bytesCopied":284,"relativePath":"evidence/client-policy-state/current/CIAgent.log"}, + {"artifactId":"policy-multiline-report-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"StateMessage.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/StateMessage.log","pathFingerprint":"synthetic:policy-multiline-report","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T13:00:10Z","bytesCopied":287,"relativePath":"evidence/client-policy-state/current/StateMessage.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..296a7a9b5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/expected.json new file mode 100644 index 000000000..43a3fb47f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/expected.json @@ -0,0 +1,39 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "persist-failure", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"}], + "artifactProvenance": [{"artifactId":"policy-persist-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}], + "transactions": [{ + "transactionId": "policy:assignment:14141414-1414-1414-1414-141414141414", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"14141414-1414-1414-1414-141414141414","policyId":"adadadad-adad-adad-adad-adadadadadad","requestId":"27141414-1414-1414-1414-141414141414","clientHandle":"safe:client:policy-14","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-persist-agent-current","startLine":1,"endLine":1}}, + "phase": "persist", + "state": "failed", + "lastSuccessfulPhase": "download", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [{"artifactId":"policy-persist-agent-current","startLine":1,"endLine":3}] + }], + "sourceLocalObservations": [], + "findings": [{ + "findingId": "finding:policy-persist-failure", + "subjectId": "policy:assignment:14141414-1414-1414-1414-141414141414", + "class": "confirmedFailure", + "phase": "persist", + "lastSuccessfulPhase": "download", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [{"artifactId":"policy-persist-agent-current","startLine":3,"endLine":3}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/manifest.json new file mode 100644 index 000000000..de389ce06 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/manifest.json @@ -0,0 +1,9 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-persist-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-persist-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T04:00:03Z","bytesCopied":969,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json new file mode 100644 index 000000000..9e5ca9947 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json @@ -0,0 +1,5910 @@ +{ + "complete": { + "artifactRequests": [], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-complete-agent-current", + "fixture-policy-complete-scheduler-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [ + "fixture-policy-complete-evaluate-current", + "fixture-policy-complete-report-current" + ], + "logicalArtifactId": "client-policy-state", + "state": "captured" + } + ], + "crossSourceCorrelationPerformed": true, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "success", + "condition": null, + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 129, + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "11111111-1111-1111-1111-111111111111", + "raw": "{11111111-1111-1111-1111-111111111111}", + "start": 91 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "11111111-1111-1111-1111-111111111111", + "raw": "{11111111-1111-1111-1111-111111111111}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "11111111-1111-1111-1111-111111111111", + "raw": "{11111111-1111-1111-1111-111111111111}", + "start": 56 + }, + { + "confidence": "exact", + "end": 130, + "evidence": { + "artifactId": "fixture-policy-complete-evaluate-current", + "entryId": "fixture-policy-complete-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "11111111-1111-1111-1111-111111111111", + "raw": "{11111111-1111-1111-1111-111111111111}", + "start": 92 + }, + { + "confidence": "exact", + "end": 128, + "evidence": { + "artifactId": "fixture-policy-complete-report-current", + "entryId": "fixture-policy-complete-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "11111111-1111-1111-1111-111111111111", + "raw": "{11111111-1111-1111-1111-111111111111}", + "start": 90 + }, + { + "confidence": "exact", + "end": 130, + "evidence": { + "artifactId": "fixture-policy-complete-scheduler-current", + "entryId": "fixture-policy-complete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "11111111-1111-1111-1111-111111111111", + "raw": "{11111111-1111-1111-1111-111111111111}", + "start": 92 + }, + { + "confidence": "exact", + "end": 177, + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "raw": "{aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}", + "start": 139 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "raw": "{aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "raw": "{aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}", + "start": 104 + }, + { + "confidence": "exact", + "end": 178, + "evidence": { + "artifactId": "fixture-policy-complete-evaluate-current", + "entryId": "fixture-policy-complete-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "raw": "{aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}", + "start": 140 + }, + { + "confidence": "exact", + "end": 176, + "evidence": { + "artifactId": "fixture-policy-complete-report-current", + "entryId": "fixture-policy-complete-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "raw": "{aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}", + "start": 138 + }, + { + "confidence": "exact", + "end": 178, + "evidence": { + "artifactId": "fixture-policy-complete-scheduler-current", + "entryId": "fixture-policy-complete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "raw": "{aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}", + "start": 140 + }, + { + "confidence": "exact", + "end": 226, + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27111111-1111-1111-1111-111111111111", + "raw": "{27111111-1111-1111-1111-111111111111}", + "start": 188 + }, + { + "confidence": "exact", + "end": 274, + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 271 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "fixture-policy-complete-evaluate-current", + "entryId": "fixture-policy-complete-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-complete-report-current", + "entryId": "fixture-policy-complete-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-complete-scheduler-current", + "entryId": "fixture-policy-complete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "11111111-1111-1111-1111-111111111111", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "requestId": "27111111-1111-1111-1111-111111111111" + }, + "lastConfirmedPhase": "report", + "nextArtifacts": [], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-complete-agent-current:1-1:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 01:00:00.000", + "utcMillis": 1785373200000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-complete-agent-current:2-2:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 01:00:01.000", + "utcMillis": 1785373201000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-complete-agent-current", + "entryId": "fixture-policy-complete-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-complete-agent-current:3-3:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 01:00:02.000", + "utcMillis": 1785373202000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-complete-scheduler-current", + "entryId": "fixture-policy-complete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-complete-scheduler-current:1-1:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 01:00:04.000", + "utcMillis": 1785373204000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-complete-evaluate-current", + "entryId": "fixture-policy-complete-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-complete-evaluate-current:1-1:Evaluate:Succeeded", + "outcome": "succeeded", + "phase": "evaluate", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 01:00:05.000", + "utcMillis": 1785373205000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-complete-report-current", + "entryId": "fixture-policy-complete-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-complete-report-current:1-1:Report:Succeeded", + "outcome": "succeeded", + "phase": "report", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 01:00:06.000", + "utcMillis": 1785373206000 + } + } + ], + "phase": "report", + "state": "succeeded", + "transactionId": "policy:assignment:11111111-1111-1111-1111-111111111111" + } + ], + "workflow": "policyAndAssignment" + }, + "contradictory-offset": { + "artifactRequests": [ + { + "logicalId": "ciAgent", + "reason": "Collect the complete CIAgent.log file.", + "role": "client" + } + ], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-offset-agent-current", + "fixture-policy-offset-scheduler-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [ + "fixture-policy-offset-evaluate-invalid", + "fixture-policy-offset-evaluate-valid", + "fixture-policy-offset-report-current" + ], + "logicalArtifactId": "client-policy-state", + "state": "captured" + } + ], + "crossSourceCorrelationPerformed": true, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [ + { + "class": "symptom", + "confidence": "low", + "correlationKeys": [ + { + "confidence": "exact", + "end": 145, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-invalid", + "entryId": "fixture-policy-offset-evaluate-invalid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "26262626-2626-2626-2626-262626262626", + "raw": "{26262626-2626-2626-2626-262626262626}", + "start": 107 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "26262626-2626-2626-2626-262626262626", + "raw": "{26262626-2626-2626-2626-262626262626}", + "start": 57 + }, + { + "confidence": "exact", + "end": 193, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-invalid", + "entryId": "fixture-policy-offset-evaluate-invalid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", + "raw": "{b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8}", + "start": 155 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", + "raw": "{b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8}", + "start": 105 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-offset-evaluate-invalid", + "entryId": "fixture-policy-offset-evaluate-invalid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "findingId": "finding:policy:assignment:26262626-2626-2626-2626-262626262626:Evaluate", + "nextArtifacts": [ + { + "logicalId": "ciAgent", + "reason": "Collect the complete CIAgent.log file.", + "role": "client" + } + ], + "phase": "policy", + "role": "client", + "severity": "Warning", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [], + "title": "Client policy workflow evidence" + } + ], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "success", + "condition": null, + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 138, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "25252525-2525-2525-2525-252525252525", + "raw": "{25252525-2525-2525-2525-252525252525}", + "start": 100 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "25252525-2525-2525-2525-252525252525", + "raw": "{25252525-2525-2525-2525-252525252525}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "25252525-2525-2525-2525-252525252525", + "raw": "{25252525-2525-2525-2525-252525252525}", + "start": 56 + }, + { + "confidence": "exact", + "end": 139, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "25252525-2525-2525-2525-252525252525", + "raw": "{25252525-2525-2525-2525-252525252525}", + "start": 101 + }, + { + "confidence": "exact", + "end": 137, + "evidence": { + "artifactId": "fixture-policy-offset-report-current", + "entryId": "fixture-policy-offset-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "25252525-2525-2525-2525-252525252525", + "raw": "{25252525-2525-2525-2525-252525252525}", + "start": 99 + }, + { + "confidence": "exact", + "end": 139, + "evidence": { + "artifactId": "fixture-policy-offset-scheduler-current", + "entryId": "fixture-policy-offset-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "25252525-2525-2525-2525-252525252525", + "raw": "{25252525-2525-2525-2525-252525252525}", + "start": 101 + }, + { + "confidence": "exact", + "end": 186, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7", + "raw": "{b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7}", + "start": 148 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7", + "raw": "{b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7", + "raw": "{b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7}", + "start": 104 + }, + { + "confidence": "exact", + "end": 187, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7", + "raw": "{b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7}", + "start": 149 + }, + { + "confidence": "exact", + "end": 185, + "evidence": { + "artifactId": "fixture-policy-offset-report-current", + "entryId": "fixture-policy-offset-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7", + "raw": "{b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7}", + "start": 147 + }, + { + "confidence": "exact", + "end": 187, + "evidence": { + "artifactId": "fixture-policy-offset-scheduler-current", + "entryId": "fixture-policy-offset-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7", + "raw": "{b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7}", + "start": 149 + }, + { + "confidence": "exact", + "end": 235, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27252525-2525-2525-2525-252525252525", + "raw": "{27252525-2525-2525-2525-252525252525}", + "start": 197 + }, + { + "confidence": "exact", + "end": 283, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 280 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-offset-report-current", + "entryId": "fixture-policy-offset-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-offset-scheduler-current", + "entryId": "fixture-policy-offset-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "25252525-2525-2525-2525-252525252525", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "b7b7b7b7-b7b7-b7b7-b7b7-b7b7b7b7b7b7", + "requestId": "27252525-2525-2525-2525-252525252525" + }, + "lastConfirmedPhase": "report", + "nextArtifacts": [], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-offset-agent-current:1-1:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 60, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 15:00:00.000", + "utcMillis": 1785420000000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-offset-agent-current:2-2:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 14:01:00.000", + "utcMillis": 1785420060000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-offset-agent-current:3-3:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 14:02:00.000", + "utcMillis": 1785420120000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-scheduler-current", + "entryId": "fixture-policy-offset-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-offset-scheduler-current:1-1:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 14:03:00.000", + "utcMillis": 1785420180000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-offset-evaluate-valid:1-1:Evaluate:Succeeded", + "outcome": "succeeded", + "phase": "evaluate", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 14:04:00.000", + "utcMillis": 1785420240000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-report-current", + "entryId": "fixture-policy-offset-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-offset-report-current:1-1:Report:Succeeded", + "outcome": "succeeded", + "phase": "report", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 14:05:00.000", + "utcMillis": 1785420300000 + } + } + ], + "phase": "report", + "state": "succeeded", + "transactionId": "policy:assignment:25252525-2525-2525-2525-252525252525" + }, + { + "classification": "contradictoryEvidence", + "condition": "orderingUnavailable", + "confidence": "low", + "correlationKeys": [ + { + "confidence": "exact", + "end": 138, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "26262626-2626-2626-2626-262626262626", + "raw": "{26262626-2626-2626-2626-262626262626}", + "start": 100 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "26262626-2626-2626-2626-262626262626", + "raw": "{26262626-2626-2626-2626-262626262626}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:6-6", + "lineEnd": 6, + "lineStart": 6 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "26262626-2626-2626-2626-262626262626", + "raw": "{26262626-2626-2626-2626-262626262626}", + "start": 56 + }, + { + "confidence": "exact", + "end": 145, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-invalid", + "entryId": "fixture-policy-offset-evaluate-invalid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "26262626-2626-2626-2626-262626262626", + "raw": "{26262626-2626-2626-2626-262626262626}", + "start": 107 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "26262626-2626-2626-2626-262626262626", + "raw": "{26262626-2626-2626-2626-262626262626}", + "start": 57 + }, + { + "confidence": "exact", + "end": 139, + "evidence": { + "artifactId": "fixture-policy-offset-scheduler-current", + "entryId": "fixture-policy-offset-scheduler-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "26262626-2626-2626-2626-262626262626", + "raw": "{26262626-2626-2626-2626-262626262626}", + "start": 101 + }, + { + "confidence": "exact", + "end": 186, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", + "raw": "{b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8}", + "start": 148 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", + "raw": "{b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:6-6", + "lineEnd": 6, + "lineStart": 6 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", + "raw": "{b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8}", + "start": 104 + }, + { + "confidence": "exact", + "end": 193, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-invalid", + "entryId": "fixture-policy-offset-evaluate-invalid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", + "raw": "{b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8}", + "start": 155 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", + "raw": "{b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8}", + "start": 105 + }, + { + "confidence": "exact", + "end": 187, + "evidence": { + "artifactId": "fixture-policy-offset-scheduler-current", + "entryId": "fixture-policy-offset-scheduler-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", + "raw": "{b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8}", + "start": 149 + }, + { + "confidence": "exact", + "end": 235, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27262626-2626-2626-2626-262626262626", + "raw": "{27262626-2626-2626-2626-262626262626}", + "start": 197 + }, + { + "confidence": "exact", + "end": 283, + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 280 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:6-6", + "lineEnd": 6, + "lineStart": 6 + }, + { + "artifactId": "fixture-policy-offset-evaluate-invalid", + "entryId": "fixture-policy-offset-evaluate-invalid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-offset-scheduler-current", + "entryId": "fixture-policy-offset-scheduler-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "assignmentId": "26262626-2626-2626-2626-262626262626", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", + "requestId": "27262626-2626-2626-2626-262626262626" + }, + "lastConfirmedPhase": "schedule", + "nextArtifacts": [ + { + "logicalId": "ciAgent", + "reason": "Collect the complete CIAgent.log file.", + "role": "client" + } + ], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "observationId": "policy-observation:fixture-policy-offset-agent-current:4-4:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 15:00:00.000", + "utcMillis": 1785423600000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + "observationId": "policy-observation:fixture-policy-offset-agent-current:5-5:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 15:01:00.000", + "utcMillis": 1785423660000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-agent-current", + "entryId": "fixture-policy-offset-agent-current:6-6", + "lineEnd": 6, + "lineStart": 6 + }, + "observationId": "policy-observation:fixture-policy-offset-agent-current:6-6:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 15:02:00.000", + "utcMillis": 1785423720000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-scheduler-current", + "entryId": "fixture-policy-offset-scheduler-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-offset-scheduler-current:2-2:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 15:03:00.000", + "utcMillis": 1785423780000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-invalid", + "entryId": "fixture-policy-offset-evaluate-invalid:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-offset-evaluate-invalid:1-1:Evaluate:Failed", + "outcome": "failed", + "phase": "evaluate", + "terminal": true, + "timestamp": { + "offsetMinutes": 9999, + "orderingState": "offsetInvalid", + "originalDisplay": "7-30-2026 15:04:00.000", + "utcMillis": null + } + }, + { + "evidence": { + "artifactId": "fixture-policy-offset-evaluate-valid", + "entryId": "fixture-policy-offset-evaluate-valid:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-offset-evaluate-valid:2-2:Evaluate:Succeeded", + "outcome": "succeeded", + "phase": "evaluate", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 15:04:00.000", + "utcMillis": 1785423840000 + } + } + ], + "phase": "evaluate", + "state": "contradictory", + "transactionId": "policy:assignment:26262626-2626-2626-2626-262626262626" + } + ], + "workflow": "policyAndAssignment" + }, + "download-failure": { + "artifactRequests": [], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-download-agent-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [], + "logicalArtifactId": "client-policy-state", + "state": "absent" + } + ], + "crossSourceCorrelationPerformed": false, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [ + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 101, + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "13131313-1313-1313-1313-131313131313", + "raw": "{13131313-1313-1313-1313-131313131313}", + "start": 63 + }, + { + "confidence": "exact", + "end": 149, + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "acacacac-acac-acac-acac-acacacacacac", + "raw": "{acacacac-acac-acac-acac-acacacacacac}", + "start": 111 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "findingId": "finding:policy:assignment:13131313-1313-1313-1313-131313131313:Download", + "nextArtifacts": [], + "phase": "policy", + "role": "client", + "severity": "Error", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + } + ], + "title": "Client policy workflow evidence" + } + ], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "confirmedFailure", + "condition": "downloadFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 137, + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "13131313-1313-1313-1313-131313131313", + "raw": "{13131313-1313-1313-1313-131313131313}", + "start": 99 + }, + { + "confidence": "exact", + "end": 101, + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "13131313-1313-1313-1313-131313131313", + "raw": "{13131313-1313-1313-1313-131313131313}", + "start": 63 + }, + { + "confidence": "exact", + "end": 185, + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "acacacac-acac-acac-acac-acacacacacac", + "raw": "{acacacac-acac-acac-acac-acacacacacac}", + "start": 147 + }, + { + "confidence": "exact", + "end": 149, + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "acacacac-acac-acac-acac-acacacacacac", + "raw": "{acacacac-acac-acac-acac-acacacacacac}", + "start": 111 + }, + { + "confidence": "exact", + "end": 234, + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27131313-1313-1313-1313-131313131313", + "raw": "{27131313-1313-1313-1313-131313131313}", + "start": 196 + }, + { + "confidence": "exact", + "end": 282, + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 279 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "assignmentId": "13131313-1313-1313-1313-131313131313", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "acacacac-acac-acac-acac-acacacacacac", + "requestId": "27131313-1313-1313-1313-131313131313" + }, + "lastConfirmedPhase": "request", + "nextArtifacts": [], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-download-agent-current:1-1:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 03:00:00.000", + "utcMillis": 1785380400000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-download-agent-current", + "entryId": "fixture-policy-download-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-download-agent-current:2-2:Download:Failed", + "outcome": "failed", + "phase": "download", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 03:00:01.000", + "utcMillis": 1785380401000 + } + } + ], + "phase": "download", + "state": "failed", + "transactionId": "policy:assignment:13131313-1313-1313-1313-131313131313" + } + ], + "workflow": "policyAndAssignment" + }, + "evaluation-failure": { + "artifactRequests": [], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-evaluation-agent-current", + "fixture-policy-evaluation-scheduler-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [ + "fixture-policy-evaluation-state-current" + ], + "logicalArtifactId": "client-policy-state", + "state": "captured" + } + ], + "crossSourceCorrelationPerformed": true, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [ + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 146, + "evidence": { + "artifactId": "fixture-policy-evaluation-state-current", + "entryId": "fixture-policy-evaluation-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "16161616-1616-1616-1616-161616161616", + "raw": "{16161616-1616-1616-1616-161616161616}", + "start": 108 + }, + { + "confidence": "exact", + "end": 194, + "evidence": { + "artifactId": "fixture-policy-evaluation-state-current", + "entryId": "fixture-policy-evaluation-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "afafafaf-afaf-afaf-afaf-afafafafafaf", + "raw": "{afafafaf-afaf-afaf-afaf-afafafafafaf}", + "start": 156 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-evaluation-state-current", + "entryId": "fixture-policy-evaluation-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "finding:policy:assignment:16161616-1616-1616-1616-161616161616:Evaluate", + "nextArtifacts": [], + "phase": "policy", + "role": "client", + "severity": "Error", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "fixture-policy-evaluation-state-current", + "entryId": "fixture-policy-evaluation-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + } + ], + "title": "Client policy workflow evidence" + } + ], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "confirmedFailure", + "condition": "evaluationFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 139, + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "16161616-1616-1616-1616-161616161616", + "raw": "{16161616-1616-1616-1616-161616161616}", + "start": 101 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "16161616-1616-1616-1616-161616161616", + "raw": "{16161616-1616-1616-1616-161616161616}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "16161616-1616-1616-1616-161616161616", + "raw": "{16161616-1616-1616-1616-161616161616}", + "start": 56 + }, + { + "confidence": "exact", + "end": 140, + "evidence": { + "artifactId": "fixture-policy-evaluation-scheduler-current", + "entryId": "fixture-policy-evaluation-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "16161616-1616-1616-1616-161616161616", + "raw": "{16161616-1616-1616-1616-161616161616}", + "start": 102 + }, + { + "confidence": "exact", + "end": 146, + "evidence": { + "artifactId": "fixture-policy-evaluation-state-current", + "entryId": "fixture-policy-evaluation-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "16161616-1616-1616-1616-161616161616", + "raw": "{16161616-1616-1616-1616-161616161616}", + "start": 108 + }, + { + "confidence": "exact", + "end": 187, + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "afafafaf-afaf-afaf-afaf-afafafafafaf", + "raw": "{afafafaf-afaf-afaf-afaf-afafafafafaf}", + "start": 149 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "afafafaf-afaf-afaf-afaf-afafafafafaf", + "raw": "{afafafaf-afaf-afaf-afaf-afafafafafaf}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "afafafaf-afaf-afaf-afaf-afafafafafaf", + "raw": "{afafafaf-afaf-afaf-afaf-afafafafafaf}", + "start": 104 + }, + { + "confidence": "exact", + "end": 188, + "evidence": { + "artifactId": "fixture-policy-evaluation-scheduler-current", + "entryId": "fixture-policy-evaluation-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "afafafaf-afaf-afaf-afaf-afafafafafaf", + "raw": "{afafafaf-afaf-afaf-afaf-afafafafafaf}", + "start": 150 + }, + { + "confidence": "exact", + "end": 194, + "evidence": { + "artifactId": "fixture-policy-evaluation-state-current", + "entryId": "fixture-policy-evaluation-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "afafafaf-afaf-afaf-afaf-afafafafafaf", + "raw": "{afafafaf-afaf-afaf-afaf-afafafafafaf}", + "start": 156 + }, + { + "confidence": "exact", + "end": 236, + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27161616-1616-1616-1616-161616161616", + "raw": "{27161616-1616-1616-1616-161616161616}", + "start": 198 + }, + { + "confidence": "exact", + "end": 284, + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 281 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "fixture-policy-evaluation-scheduler-current", + "entryId": "fixture-policy-evaluation-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-evaluation-state-current", + "entryId": "fixture-policy-evaluation-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "16161616-1616-1616-1616-161616161616", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "afafafaf-afaf-afaf-afaf-afafafafafaf", + "requestId": "27161616-1616-1616-1616-161616161616" + }, + "lastConfirmedPhase": "schedule", + "nextArtifacts": [], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-evaluation-agent-current:1-1:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 06:00:00.000", + "utcMillis": 1785391200000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-evaluation-agent-current:2-2:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 06:00:01.000", + "utcMillis": 1785391201000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-evaluation-agent-current", + "entryId": "fixture-policy-evaluation-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-evaluation-agent-current:3-3:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 06:00:02.000", + "utcMillis": 1785391202000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-evaluation-scheduler-current", + "entryId": "fixture-policy-evaluation-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-evaluation-scheduler-current:1-1:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 06:00:03.000", + "utcMillis": 1785391203000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-evaluation-state-current", + "entryId": "fixture-policy-evaluation-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-evaluation-state-current:1-1:Evaluate:Failed", + "outcome": "failed", + "phase": "evaluate", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 06:00:04.000", + "utcMillis": 1785391204000 + } + } + ], + "phase": "evaluate", + "state": "failed", + "transactionId": "policy:assignment:16161616-1616-1616-1616-161616161616" + } + ], + "workflow": "policyAndAssignment" + }, + "gate-c-contradictory": { + "artifactRequests": [ + { + "logicalId": "ciAgent", + "reason": "Collect the complete CIAgent.log file.", + "role": "client" + } + ], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-gate-c-agent-current", + "fixture-policy-gate-c-scheduler-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [ + "fixture-policy-gate-c-evaluate-failure", + "fixture-policy-gate-c-evaluate-success", + "fixture-policy-gate-c-report-current" + ], + "logicalArtifactId": "client-policy-state", + "state": "captured" + } + ], + "crossSourceCorrelationPerformed": true, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [ + { + "class": "symptom", + "confidence": "low", + "correlationKeys": [ + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-failure", + "entryId": "fixture-policy-gate-c-evaluate-failure:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "19191919-1919-1919-1919-191919191919", + "raw": "{19191919-1919-1919-1919-191919191919}", + "start": 105 + }, + { + "confidence": "exact", + "end": 137, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "19191919-1919-1919-1919-191919191919", + "raw": "{19191919-1919-1919-1919-191919191919}", + "start": 99 + }, + { + "confidence": "exact", + "end": 191, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-failure", + "entryId": "fixture-policy-gate-c-evaluate-failure:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2", + "raw": "{b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2}", + "start": 153 + }, + { + "confidence": "exact", + "end": 185, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2", + "raw": "{b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2}", + "start": 147 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-gate-c-evaluate-failure", + "entryId": "fixture-policy-gate-c-evaluate-failure:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "finding:policy:assignment:19191919-1919-1919-1919-191919191919:Evaluate", + "nextArtifacts": [ + { + "logicalId": "ciAgent", + "reason": "Collect the complete CIAgent.log file.", + "role": "client" + } + ], + "phase": "policy", + "role": "client", + "severity": "Warning", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [], + "title": "Client policy workflow evidence" + }, + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 132, + "evidence": { + "artifactId": "fixture-policy-gate-c-report-current", + "entryId": "fixture-policy-gate-c-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "20202020-2020-2020-2020-202020202020", + "raw": "{20202020-2020-2020-2020-202020202020}", + "start": 94 + }, + { + "confidence": "exact", + "end": 180, + "evidence": { + "artifactId": "fixture-policy-gate-c-report-current", + "entryId": "fixture-policy-gate-c-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3", + "raw": "{b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3}", + "start": 142 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-gate-c-report-current", + "entryId": "fixture-policy-gate-c-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "finding:policy:assignment:20202020-2020-2020-2020-202020202020:Report", + "nextArtifacts": [], + "phase": "policy", + "role": "client", + "severity": "Error", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "fixture-policy-gate-c-report-current", + "entryId": "fixture-policy-gate-c-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + } + ], + "title": "Client policy workflow evidence" + } + ], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "contradictoryEvidence", + "condition": "conflictingEvidence", + "confidence": "low", + "correlationKeys": [ + { + "confidence": "exact", + "end": 127, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "19191919-1919-1919-1919-191919191919", + "raw": "{19191919-1919-1919-1919-191919191919}", + "start": 89 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "19191919-1919-1919-1919-191919191919", + "raw": "{19191919-1919-1919-1919-191919191919}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "19191919-1919-1919-1919-191919191919", + "raw": "{19191919-1919-1919-1919-191919191919}", + "start": 56 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-failure", + "entryId": "fixture-policy-gate-c-evaluate-failure:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "19191919-1919-1919-1919-191919191919", + "raw": "{19191919-1919-1919-1919-191919191919}", + "start": 105 + }, + { + "confidence": "exact", + "end": 137, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "19191919-1919-1919-1919-191919191919", + "raw": "{19191919-1919-1919-1919-191919191919}", + "start": 99 + }, + { + "confidence": "exact", + "end": 128, + "evidence": { + "artifactId": "fixture-policy-gate-c-scheduler-current", + "entryId": "fixture-policy-gate-c-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "19191919-1919-1919-1919-191919191919", + "raw": "{19191919-1919-1919-1919-191919191919}", + "start": 90 + }, + { + "confidence": "exact", + "end": 175, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2", + "raw": "{b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2}", + "start": 137 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2", + "raw": "{b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2", + "raw": "{b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2}", + "start": 104 + }, + { + "confidence": "exact", + "end": 191, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-failure", + "entryId": "fixture-policy-gate-c-evaluate-failure:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2", + "raw": "{b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2}", + "start": 153 + }, + { + "confidence": "exact", + "end": 185, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2", + "raw": "{b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2}", + "start": 147 + }, + { + "confidence": "exact", + "end": 176, + "evidence": { + "artifactId": "fixture-policy-gate-c-scheduler-current", + "entryId": "fixture-policy-gate-c-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2", + "raw": "{b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2}", + "start": 138 + }, + { + "confidence": "exact", + "end": 224, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27191919-1919-1919-1919-191919191919", + "raw": "{27191919-1919-1919-1919-191919191919}", + "start": 186 + }, + { + "confidence": "exact", + "end": 272, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 269 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "fixture-policy-gate-c-evaluate-failure", + "entryId": "fixture-policy-gate-c-evaluate-failure:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-gate-c-scheduler-current", + "entryId": "fixture-policy-gate-c-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "19191919-1919-1919-1919-191919191919", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2", + "requestId": "27191919-1919-1919-1919-191919191919" + }, + "lastConfirmedPhase": "schedule", + "nextArtifacts": [ + { + "logicalId": "ciAgent", + "reason": "Collect the complete CIAgent.log file.", + "role": "client" + } + ], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-gate-c-agent-current:1-1:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:00.000", + "utcMillis": 1785409200000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-gate-c-agent-current:2-2:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:01.000", + "utcMillis": 1785409201000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-gate-c-agent-current:3-3:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:02.000", + "utcMillis": 1785409202000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-scheduler-current", + "entryId": "fixture-policy-gate-c-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-gate-c-scheduler-current:1-1:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:03.000", + "utcMillis": 1785409203000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-failure", + "entryId": "fixture-policy-gate-c-evaluate-failure:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-gate-c-evaluate-failure:1-1:Evaluate:Failed", + "outcome": "failed", + "phase": "evaluate", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:04.000", + "utcMillis": 1785409204000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-gate-c-evaluate-success:1-1:Evaluate:Succeeded", + "outcome": "succeeded", + "phase": "evaluate", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:04.000", + "utcMillis": 1785409204000 + } + } + ], + "phase": "evaluate", + "state": "contradictory", + "transactionId": "policy:assignment:19191919-1919-1919-1919-191919191919" + }, + { + "classification": "confirmedFailure", + "condition": "reportingFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "20202020-2020-2020-2020-202020202020", + "raw": "{20202020-2020-2020-2020-202020202020}", + "start": 56 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "20202020-2020-2020-2020-202020202020", + "raw": "{20202020-2020-2020-2020-202020202020}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:6-6", + "lineEnd": 6, + "lineStart": 6 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "20202020-2020-2020-2020-202020202020", + "raw": "{20202020-2020-2020-2020-202020202020}", + "start": 56 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "20202020-2020-2020-2020-202020202020", + "raw": "{20202020-2020-2020-2020-202020202020}", + "start": 57 + }, + { + "confidence": "exact", + "end": 132, + "evidence": { + "artifactId": "fixture-policy-gate-c-report-current", + "entryId": "fixture-policy-gate-c-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "20202020-2020-2020-2020-202020202020", + "raw": "{20202020-2020-2020-2020-202020202020}", + "start": 94 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-gate-c-scheduler-current", + "entryId": "fixture-policy-gate-c-scheduler-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "20202020-2020-2020-2020-202020202020", + "raw": "{20202020-2020-2020-2020-202020202020}", + "start": 57 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3", + "raw": "{b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3}", + "start": 104 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3", + "raw": "{b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:6-6", + "lineEnd": 6, + "lineStart": 6 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3", + "raw": "{b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3}", + "start": 104 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3", + "raw": "{b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3}", + "start": 105 + }, + { + "confidence": "exact", + "end": 180, + "evidence": { + "artifactId": "fixture-policy-gate-c-report-current", + "entryId": "fixture-policy-gate-c-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3", + "raw": "{b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3}", + "start": 142 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-gate-c-scheduler-current", + "entryId": "fixture-policy-gate-c-scheduler-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3", + "raw": "{b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3}", + "start": 105 + }, + { + "confidence": "exact", + "end": 191, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27202020-2020-2020-2020-202020202020", + "raw": "{27202020-2020-2020-2020-202020202020}", + "start": 153 + }, + { + "confidence": "exact", + "end": 239, + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 236 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:6-6", + "lineEnd": 6, + "lineStart": 6 + }, + { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-gate-c-report-current", + "entryId": "fixture-policy-gate-c-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-gate-c-scheduler-current", + "entryId": "fixture-policy-gate-c-scheduler-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "assignmentId": "20202020-2020-2020-2020-202020202020", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "b3b3b3b3-b3b3-b3b3-b3b3-b3b3b3b3b3b3", + "requestId": "27202020-2020-2020-2020-202020202020" + }, + "lastConfirmedPhase": "evaluate", + "nextArtifacts": [], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "observationId": "policy-observation:fixture-policy-gate-c-agent-current:4-4:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:00.000", + "utcMillis": 1785409200000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + "observationId": "policy-observation:fixture-policy-gate-c-agent-current:5-5:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:01.000", + "utcMillis": 1785409201000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-agent-current", + "entryId": "fixture-policy-gate-c-agent-current:6-6", + "lineEnd": 6, + "lineStart": 6 + }, + "observationId": "policy-observation:fixture-policy-gate-c-agent-current:6-6:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:02.000", + "utcMillis": 1785409202000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-scheduler-current", + "entryId": "fixture-policy-gate-c-scheduler-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-gate-c-scheduler-current:2-2:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:03.000", + "utcMillis": 1785409203000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-evaluate-success", + "entryId": "fixture-policy-gate-c-evaluate-success:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-gate-c-evaluate-success:2-2:Evaluate:Succeeded", + "outcome": "succeeded", + "phase": "evaluate", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:04.000", + "utcMillis": 1785409204000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-gate-c-report-current", + "entryId": "fixture-policy-gate-c-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-gate-c-report-current:1-1:Report:Failed", + "outcome": "failed", + "phase": "report", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 11:00:05.000", + "utcMillis": 1785409205000 + } + } + ], + "phase": "report", + "state": "failed", + "transactionId": "policy:assignment:20202020-2020-2020-2020-202020202020" + } + ], + "workflow": "policyAndAssignment" + }, + "incomplete": { + "artifactRequests": [ + { + "logicalId": "ciAgent", + "reason": "Collect the complete CIAgent.log file.", + "role": "client" + } + ], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-incomplete-agent-current", + "fixture-policy-incomplete-scheduler-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [ + "fixture-policy-incomplete-state-absent" + ], + "logicalArtifactId": "client-policy-state", + "state": "absent" + } + ], + "crossSourceCorrelationPerformed": false, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [ + { + "class": "insufficientEvidence", + "confidence": "moderate", + "correlationKeys": [ + { + "confidence": "exact", + "end": 132, + "evidence": { + "artifactId": "fixture-policy-incomplete-scheduler-current", + "entryId": "fixture-policy-incomplete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "18181818-1818-1818-1818-181818181818", + "raw": "{18181818-1818-1818-1818-181818181818}", + "start": 94 + }, + { + "confidence": "exact", + "end": 180, + "evidence": { + "artifactId": "fixture-policy-incomplete-scheduler-current", + "entryId": "fixture-policy-incomplete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1", + "raw": "{b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1}", + "start": 142 + } + ], + "coverageGaps": [ + { + "artifactId": "client-policy-state", + "coverage": "absent", + "role": "client" + } + ], + "evidence": [ + { + "artifactId": "fixture-policy-incomplete-scheduler-current", + "entryId": "fixture-policy-incomplete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "finding:policy:assignment:18181818-1818-1818-1818-181818181818:Evaluate", + "nextArtifacts": [ + { + "logicalId": "ciAgent", + "reason": "Collect the complete CIAgent.log file.", + "role": "client" + } + ], + "phase": "policy", + "role": "client", + "severity": "Warning", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [], + "title": "Client policy workflow evidence" + } + ], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "insufficientEvidence", + "condition": "coverageGap", + "confidence": "moderate", + "correlationKeys": [ + { + "confidence": "exact", + "end": 131, + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "18181818-1818-1818-1818-181818181818", + "raw": "{18181818-1818-1818-1818-181818181818}", + "start": 93 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "18181818-1818-1818-1818-181818181818", + "raw": "{18181818-1818-1818-1818-181818181818}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "18181818-1818-1818-1818-181818181818", + "raw": "{18181818-1818-1818-1818-181818181818}", + "start": 56 + }, + { + "confidence": "exact", + "end": 132, + "evidence": { + "artifactId": "fixture-policy-incomplete-scheduler-current", + "entryId": "fixture-policy-incomplete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "18181818-1818-1818-1818-181818181818", + "raw": "{18181818-1818-1818-1818-181818181818}", + "start": 94 + }, + { + "confidence": "exact", + "end": 179, + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1", + "raw": "{b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1}", + "start": 141 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1", + "raw": "{b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1", + "raw": "{b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1}", + "start": 104 + }, + { + "confidence": "exact", + "end": 180, + "evidence": { + "artifactId": "fixture-policy-incomplete-scheduler-current", + "entryId": "fixture-policy-incomplete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1", + "raw": "{b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1}", + "start": 142 + }, + { + "confidence": "exact", + "end": 228, + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27181818-1818-1818-1818-181818181818", + "raw": "{27181818-1818-1818-1818-181818181818}", + "start": 190 + }, + { + "confidence": "exact", + "end": 276, + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 273 + } + ], + "coverageGaps": [ + { + "artifactId": "client-policy-state", + "coverage": "absent", + "role": "client" + } + ], + "evidence": [ + { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "fixture-policy-incomplete-scheduler-current", + "entryId": "fixture-policy-incomplete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "18181818-1818-1818-1818-181818181818", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1", + "requestId": "27181818-1818-1818-1818-181818181818" + }, + "lastConfirmedPhase": "schedule", + "nextArtifacts": [ + { + "logicalId": "ciAgent", + "reason": "Collect the complete CIAgent.log file.", + "role": "client" + } + ], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-incomplete-agent-current:1-1:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 10:00:00.000", + "utcMillis": 1785405600000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-incomplete-agent-current:2-2:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 10:00:01.000", + "utcMillis": 1785405601000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-incomplete-agent-current", + "entryId": "fixture-policy-incomplete-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-incomplete-agent-current:3-3:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 10:00:02.000", + "utcMillis": 1785405602000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-incomplete-scheduler-current", + "entryId": "fixture-policy-incomplete-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-incomplete-scheduler-current:1-1:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 10:00:03.000", + "utcMillis": 1785405603000 + } + } + ], + "phase": "evaluate", + "state": "incomplete", + "transactionId": "policy:assignment:18181818-1818-1818-1818-181818181818" + } + ], + "workflow": "policyAndAssignment" + }, + "malformed": { + "artifactRequests": [ + { + "logicalId": "policyAgent", + "reason": "Collect the complete PolicyAgent.log file.", + "role": "client" + } + ], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-malformed-agent-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [], + "logicalArtifactId": "client-policy-state", + "state": "absent" + } + ], + "crossSourceCorrelationPerformed": false, + "extractionProfile": { + "selectionState": "unvalidatedVersion", + "syntheticFixtureOnly": true + }, + "findings": [], + "profileGaps": [ + { + "artifactId": "fixture-policy-malformed-agent-current", + "condition": "unknownProfile", + "evidence": { + "artifactId": "fixture-policy-malformed-agent-current", + "entryId": "fixture-policy-malformed-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "selectedConfigmgrVersion": "5.00.UNKNOWN.0000" + } + ], + "sourceLocalObservations": [ + { + "artifactIds": [ + "fixture-policy-malformed-agent-current" + ], + "classification": "lowConfidenceSymptom", + "condition": "unknownProfile", + "confidence": "low", + "correlationEligible": false, + "evidence": [ + { + "artifactId": "fixture-policy-malformed-agent-current", + "entryId": "fixture-policy-malformed-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "nextArtifacts": [ + { + "logicalId": "policyAgent", + "reason": "Collect the complete PolicyAgent.log file.", + "role": "client" + } + ], + "observationId": "policy-source:fixture-policy-malformed-agent-current:1-1:UnknownProfile", + "state": "observed" + } + ], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [], + "workflow": "policyAndAssignment" + }, + "multiline": { + "artifactRequests": [], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-multiline-agent-current", + "fixture-policy-multiline-scheduler-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [ + "fixture-policy-multiline-evaluate-current", + "fixture-policy-multiline-report-current" + ], + "logicalArtifactId": "client-policy-state", + "state": "captured" + } + ], + "crossSourceCorrelationPerformed": true, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "success", + "condition": null, + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 130, + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:1-2", + "lineEnd": 2, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "24242424-2424-2424-2424-242424242424", + "raw": "{24242424-2424-2424-2424-242424242424}", + "start": 92 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "24242424-2424-2424-2424-242424242424", + "raw": "{24242424-2424-2424-2424-242424242424}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "24242424-2424-2424-2424-242424242424", + "raw": "{24242424-2424-2424-2424-242424242424}", + "start": 56 + }, + { + "confidence": "exact", + "end": 131, + "evidence": { + "artifactId": "fixture-policy-multiline-evaluate-current", + "entryId": "fixture-policy-multiline-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "24242424-2424-2424-2424-242424242424", + "raw": "{24242424-2424-2424-2424-242424242424}", + "start": 93 + }, + { + "confidence": "exact", + "end": 129, + "evidence": { + "artifactId": "fixture-policy-multiline-report-current", + "entryId": "fixture-policy-multiline-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "24242424-2424-2424-2424-242424242424", + "raw": "{24242424-2424-2424-2424-242424242424}", + "start": 91 + }, + { + "confidence": "exact", + "end": 131, + "evidence": { + "artifactId": "fixture-policy-multiline-scheduler-current", + "entryId": "fixture-policy-multiline-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "24242424-2424-2424-2424-242424242424", + "raw": "{24242424-2424-2424-2424-242424242424}", + "start": 93 + }, + { + "confidence": "exact", + "end": 178, + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:1-2", + "lineEnd": 2, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6", + "raw": "{b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6}", + "start": 140 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6", + "raw": "{b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6", + "raw": "{b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6}", + "start": 104 + }, + { + "confidence": "exact", + "end": 179, + "evidence": { + "artifactId": "fixture-policy-multiline-evaluate-current", + "entryId": "fixture-policy-multiline-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6", + "raw": "{b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6}", + "start": 141 + }, + { + "confidence": "exact", + "end": 177, + "evidence": { + "artifactId": "fixture-policy-multiline-report-current", + "entryId": "fixture-policy-multiline-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6", + "raw": "{b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6}", + "start": 139 + }, + { + "confidence": "exact", + "end": 179, + "evidence": { + "artifactId": "fixture-policy-multiline-scheduler-current", + "entryId": "fixture-policy-multiline-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6", + "raw": "{b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6}", + "start": 141 + }, + { + "confidence": "exact", + "end": 227, + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:1-2", + "lineEnd": 2, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27242424-2424-2424-2424-242424242424", + "raw": "{27242424-2424-2424-2424-242424242424}", + "start": 189 + }, + { + "confidence": "exact", + "end": 275, + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:1-2", + "lineEnd": 2, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 272 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:1-2", + "lineEnd": 2, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + { + "artifactId": "fixture-policy-multiline-evaluate-current", + "entryId": "fixture-policy-multiline-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-multiline-report-current", + "entryId": "fixture-policy-multiline-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-multiline-scheduler-current", + "entryId": "fixture-policy-multiline-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "24242424-2424-2424-2424-242424242424", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "b6b6b6b6-b6b6-b6b6-b6b6-b6b6b6b6b6b6", + "requestId": "27242424-2424-2424-2424-242424242424" + }, + "lastConfirmedPhase": "report", + "nextArtifacts": [], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:1-2", + "lineEnd": 2, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-multiline-agent-current:1-2:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 13:00:00.000", + "utcMillis": 1785416400000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-multiline-agent-current:3-3:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 13:00:01.000", + "utcMillis": 1785416401000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-multiline-agent-current", + "entryId": "fixture-policy-multiline-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "observationId": "policy-observation:fixture-policy-multiline-agent-current:4-4:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 13:00:02.000", + "utcMillis": 1785416402000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-multiline-scheduler-current", + "entryId": "fixture-policy-multiline-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-multiline-scheduler-current:1-1:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 13:00:03.000", + "utcMillis": 1785416403000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-multiline-evaluate-current", + "entryId": "fixture-policy-multiline-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-multiline-evaluate-current:1-1:Evaluate:Succeeded", + "outcome": "succeeded", + "phase": "evaluate", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 13:00:04.000", + "utcMillis": 1785416404000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-multiline-report-current", + "entryId": "fixture-policy-multiline-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-multiline-report-current:1-1:Report:Succeeded", + "outcome": "succeeded", + "phase": "report", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 13:00:05.000", + "utcMillis": 1785416405000 + } + } + ], + "phase": "report", + "state": "succeeded", + "transactionId": "policy:assignment:24242424-2424-2424-2424-242424242424" + } + ], + "workflow": "policyAndAssignment" + }, + "persist-failure": { + "artifactRequests": [], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-persist-agent-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [], + "logicalArtifactId": "client-policy-state", + "state": "absent" + } + ], + "crossSourceCorrelationPerformed": false, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [ + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 100, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "14141414-1414-1414-1414-141414141414", + "raw": "{14141414-1414-1414-1414-141414141414}", + "start": 62 + }, + { + "confidence": "exact", + "end": 148, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "adadadad-adad-adad-adad-adadadadadad", + "raw": "{adadadad-adad-adad-adad-adadadadadad}", + "start": 110 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "findingId": "finding:policy:assignment:14141414-1414-1414-1414-141414141414:Persist", + "nextArtifacts": [], + "phase": "policy", + "role": "client", + "severity": "Error", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + } + ], + "title": "Client policy workflow evidence" + } + ], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "confirmedFailure", + "condition": "processingFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 136, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "14141414-1414-1414-1414-141414141414", + "raw": "{14141414-1414-1414-1414-141414141414}", + "start": 98 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "14141414-1414-1414-1414-141414141414", + "raw": "{14141414-1414-1414-1414-141414141414}", + "start": 57 + }, + { + "confidence": "exact", + "end": 100, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "14141414-1414-1414-1414-141414141414", + "raw": "{14141414-1414-1414-1414-141414141414}", + "start": 62 + }, + { + "confidence": "exact", + "end": 184, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "adadadad-adad-adad-adad-adadadadadad", + "raw": "{adadadad-adad-adad-adad-adadadadadad}", + "start": 146 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "adadadad-adad-adad-adad-adadadadadad", + "raw": "{adadadad-adad-adad-adad-adadadadadad}", + "start": 105 + }, + { + "confidence": "exact", + "end": 148, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "adadadad-adad-adad-adad-adadadadadad", + "raw": "{adadadad-adad-adad-adad-adadadadadad}", + "start": 110 + }, + { + "confidence": "exact", + "end": 233, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27141414-1414-1414-1414-141414141414", + "raw": "{27141414-1414-1414-1414-141414141414}", + "start": 195 + }, + { + "confidence": "exact", + "end": 281, + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 278 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "key": { + "assignmentId": "14141414-1414-1414-1414-141414141414", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "adadadad-adad-adad-adad-adadadadadad", + "requestId": "27141414-1414-1414-1414-141414141414" + }, + "lastConfirmedPhase": "download", + "nextArtifacts": [], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-persist-agent-current:1-1:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 04:00:00.000", + "utcMillis": 1785384000000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-persist-agent-current:2-2:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 04:00:01.000", + "utcMillis": 1785384001000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-persist-agent-current", + "entryId": "fixture-policy-persist-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-persist-agent-current:3-3:Persist:Failed", + "outcome": "failed", + "phase": "persist", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 04:00:02.000", + "utcMillis": 1785384002000 + } + } + ], + "phase": "persist", + "state": "failed", + "transactionId": "policy:assignment:14141414-1414-1414-1414-141414141414" + } + ], + "workflow": "policyAndAssignment" + }, + "recovery": { + "artifactRequests": [], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-recovery-agent-current", + "fixture-policy-recovery-scheduler-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [ + "fixture-policy-recovery-evaluate-current", + "fixture-policy-recovery-report-current" + ], + "logicalArtifactId": "client-policy-state", + "state": "captured" + } + ], + "crossSourceCorrelationPerformed": true, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "success", + "condition": null, + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 129, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "23232323-2323-2323-2323-232323232323", + "raw": "{23232323-2323-2323-2323-232323232323}", + "start": 91 + }, + { + "confidence": "exact", + "end": 101, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "23232323-2323-2323-2323-232323232323", + "raw": "{23232323-2323-2323-2323-232323232323}", + "start": 63 + }, + { + "confidence": "exact", + "end": 104, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "23232323-2323-2323-2323-232323232323", + "raw": "{23232323-2323-2323-2323-232323232323}", + "start": 66 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "23232323-2323-2323-2323-232323232323", + "raw": "{23232323-2323-2323-2323-232323232323}", + "start": 56 + }, + { + "confidence": "exact", + "end": 130, + "evidence": { + "artifactId": "fixture-policy-recovery-evaluate-current", + "entryId": "fixture-policy-recovery-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "23232323-2323-2323-2323-232323232323", + "raw": "{23232323-2323-2323-2323-232323232323}", + "start": 92 + }, + { + "confidence": "exact", + "end": 128, + "evidence": { + "artifactId": "fixture-policy-recovery-report-current", + "entryId": "fixture-policy-recovery-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "23232323-2323-2323-2323-232323232323", + "raw": "{23232323-2323-2323-2323-232323232323}", + "start": 90 + }, + { + "confidence": "exact", + "end": 130, + "evidence": { + "artifactId": "fixture-policy-recovery-scheduler-current", + "entryId": "fixture-policy-recovery-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "23232323-2323-2323-2323-232323232323", + "raw": "{23232323-2323-2323-2323-232323232323}", + "start": 92 + }, + { + "confidence": "exact", + "end": 177, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5", + "raw": "{b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5}", + "start": 139 + }, + { + "confidence": "exact", + "end": 149, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5", + "raw": "{b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5}", + "start": 111 + }, + { + "confidence": "exact", + "end": 152, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5", + "raw": "{b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5}", + "start": 114 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5", + "raw": "{b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5}", + "start": 104 + }, + { + "confidence": "exact", + "end": 178, + "evidence": { + "artifactId": "fixture-policy-recovery-evaluate-current", + "entryId": "fixture-policy-recovery-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5", + "raw": "{b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5}", + "start": 140 + }, + { + "confidence": "exact", + "end": 176, + "evidence": { + "artifactId": "fixture-policy-recovery-report-current", + "entryId": "fixture-policy-recovery-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5", + "raw": "{b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5}", + "start": 138 + }, + { + "confidence": "exact", + "end": 178, + "evidence": { + "artifactId": "fixture-policy-recovery-scheduler-current", + "entryId": "fixture-policy-recovery-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5", + "raw": "{b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5}", + "start": 140 + }, + { + "confidence": "exact", + "end": 226, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27232323-2323-2323-2323-232323232323", + "raw": "{27232323-2323-2323-2323-232323232323}", + "start": 188 + }, + { + "confidence": "exact", + "end": 274, + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 271 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + { + "artifactId": "fixture-policy-recovery-evaluate-current", + "entryId": "fixture-policy-recovery-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-recovery-report-current", + "entryId": "fixture-policy-recovery-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-recovery-scheduler-current", + "entryId": "fixture-policy-recovery-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "23232323-2323-2323-2323-232323232323", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5", + "requestId": "27232323-2323-2323-2323-232323232323" + }, + "lastConfirmedPhase": "report", + "nextArtifacts": [], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-recovery-agent-current:1-1:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 12:00:00.000", + "utcMillis": 1785412800000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-recovery-agent-current:2-2:Download:Failed", + "outcome": "failed", + "phase": "download", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 12:00:01.000", + "utcMillis": 1785412801000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-recovery-agent-current:3-3:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 12:00:02.000", + "utcMillis": 1785412802000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-recovery-agent-current", + "entryId": "fixture-policy-recovery-agent-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "observationId": "policy-observation:fixture-policy-recovery-agent-current:4-4:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 12:00:03.000", + "utcMillis": 1785412803000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-recovery-scheduler-current", + "entryId": "fixture-policy-recovery-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-recovery-scheduler-current:1-1:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 12:00:04.000", + "utcMillis": 1785412804000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-recovery-evaluate-current", + "entryId": "fixture-policy-recovery-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-recovery-evaluate-current:1-1:Evaluate:Succeeded", + "outcome": "succeeded", + "phase": "evaluate", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 12:00:05.000", + "utcMillis": 1785412805000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-recovery-report-current", + "entryId": "fixture-policy-recovery-report-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-recovery-report-current:1-1:Report:Succeeded", + "outcome": "succeeded", + "phase": "report", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 12:00:06.000", + "utcMillis": 1785412806000 + } + } + ], + "phase": "report", + "state": "succeeded", + "transactionId": "policy:assignment:23232323-2323-2323-2323-232323232323" + } + ], + "workflow": "policyAndAssignment" + }, + "reporting-failure": { + "artifactRequests": [], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-reporting-agent-current", + "fixture-policy-reporting-scheduler-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [ + "fixture-policy-reporting-evaluate-current", + "fixture-policy-reporting-state-current" + ], + "logicalArtifactId": "client-policy-state", + "state": "captured" + } + ], + "crossSourceCorrelationPerformed": true, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [ + { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-reporting-state-current", + "entryId": "fixture-policy-reporting-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "17171717-1717-1717-1717-171717171717", + "raw": "{17171717-1717-1717-1717-171717171717}", + "start": 105 + }, + { + "confidence": "exact", + "end": 191, + "evidence": { + "artifactId": "fixture-policy-reporting-state-current", + "entryId": "fixture-policy-reporting-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0", + "raw": "{b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0}", + "start": 153 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-reporting-state-current", + "entryId": "fixture-policy-reporting-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "finding:policy:assignment:17171717-1717-1717-1717-171717171717:Report", + "nextArtifacts": [], + "phase": "policy", + "role": "client", + "severity": "Error", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "fixture-policy-reporting-state-current", + "entryId": "fixture-policy-reporting-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + } + ], + "title": "Client policy workflow evidence" + } + ], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "confirmedFailure", + "condition": "reportingFailure", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 138, + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "17171717-1717-1717-1717-171717171717", + "raw": "{17171717-1717-1717-1717-171717171717}", + "start": 100 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "17171717-1717-1717-1717-171717171717", + "raw": "{17171717-1717-1717-1717-171717171717}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "17171717-1717-1717-1717-171717171717", + "raw": "{17171717-1717-1717-1717-171717171717}", + "start": 56 + }, + { + "confidence": "exact", + "end": 139, + "evidence": { + "artifactId": "fixture-policy-reporting-evaluate-current", + "entryId": "fixture-policy-reporting-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "17171717-1717-1717-1717-171717171717", + "raw": "{17171717-1717-1717-1717-171717171717}", + "start": 101 + }, + { + "confidence": "exact", + "end": 139, + "evidence": { + "artifactId": "fixture-policy-reporting-scheduler-current", + "entryId": "fixture-policy-reporting-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "17171717-1717-1717-1717-171717171717", + "raw": "{17171717-1717-1717-1717-171717171717}", + "start": 101 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-reporting-state-current", + "entryId": "fixture-policy-reporting-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "17171717-1717-1717-1717-171717171717", + "raw": "{17171717-1717-1717-1717-171717171717}", + "start": 105 + }, + { + "confidence": "exact", + "end": 186, + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0", + "raw": "{b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0}", + "start": 148 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0", + "raw": "{b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0", + "raw": "{b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0}", + "start": 104 + }, + { + "confidence": "exact", + "end": 187, + "evidence": { + "artifactId": "fixture-policy-reporting-evaluate-current", + "entryId": "fixture-policy-reporting-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0", + "raw": "{b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0}", + "start": 149 + }, + { + "confidence": "exact", + "end": 187, + "evidence": { + "artifactId": "fixture-policy-reporting-scheduler-current", + "entryId": "fixture-policy-reporting-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0", + "raw": "{b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0}", + "start": 149 + }, + { + "confidence": "exact", + "end": 191, + "evidence": { + "artifactId": "fixture-policy-reporting-state-current", + "entryId": "fixture-policy-reporting-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0", + "raw": "{b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0}", + "start": 153 + }, + { + "confidence": "exact", + "end": 235, + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27171717-1717-1717-1717-171717171717", + "raw": "{27171717-1717-1717-1717-171717171717}", + "start": 197 + }, + { + "confidence": "exact", + "end": 283, + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 280 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "fixture-policy-reporting-evaluate-current", + "entryId": "fixture-policy-reporting-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-reporting-scheduler-current", + "entryId": "fixture-policy-reporting-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-reporting-state-current", + "entryId": "fixture-policy-reporting-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "17171717-1717-1717-1717-171717171717", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0", + "requestId": "27171717-1717-1717-1717-171717171717" + }, + "lastConfirmedPhase": "evaluate", + "nextArtifacts": [], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-reporting-agent-current:1-1:Request:Succeeded", + "outcome": "succeeded", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 07:00:00.000", + "utcMillis": 1785394800000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-reporting-agent-current:2-2:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 07:00:01.000", + "utcMillis": 1785394801000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-reporting-agent-current", + "entryId": "fixture-policy-reporting-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-reporting-agent-current:3-3:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 07:00:02.000", + "utcMillis": 1785394802000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-reporting-scheduler-current", + "entryId": "fixture-policy-reporting-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-reporting-scheduler-current:1-1:Schedule:Succeeded", + "outcome": "succeeded", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 07:00:03.000", + "utcMillis": 1785394803000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-reporting-evaluate-current", + "entryId": "fixture-policy-reporting-evaluate-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-reporting-evaluate-current:1-1:Evaluate:Succeeded", + "outcome": "succeeded", + "phase": "evaluate", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 07:00:04.000", + "utcMillis": 1785394804000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-reporting-state-current", + "entryId": "fixture-policy-reporting-state-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-reporting-state-current:1-1:Report:Failed", + "outcome": "failed", + "phase": "report", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 07:00:05.000", + "utcMillis": 1785394805000 + } + } + ], + "phase": "report", + "state": "failed", + "transactionId": "policy:assignment:17171717-1717-1717-1717-171717171717" + } + ], + "workflow": "policyAndAssignment" + }, + "request-auth-failure": { + "artifactRequests": [ + { + "logicalId": "clientLocation", + "reason": "Collect the complete ClientLocation.log file.", + "role": "client" + } + ], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-auth-agent-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [], + "logicalArtifactId": "client-policy-state", + "state": "absent" + } + ], + "crossSourceCorrelationPerformed": false, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [ + { + "class": "confirmedFailure", + "confidence": "moderate", + "correlationKeys": [ + { + "confidence": "exact", + "end": 154, + "evidence": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "12121212-1212-1212-1212-121212121212", + "raw": "{12121212-1212-1212-1212-121212121212}", + "start": 116 + }, + { + "confidence": "exact", + "end": 202, + "evidence": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "abababab-abab-abab-abab-abababababab", + "raw": "{abababab-abab-abab-abab-abababababab}", + "start": 164 + }, + { + "confidence": "exact", + "end": 299, + "evidence": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 296 + }, + { + "confidence": "exact", + "end": 251, + "evidence": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27121212-1212-1212-1212-121212121212", + "raw": "{27121212-1212-1212-1212-121212121212}", + "start": 213 + } + ], + "coverageGaps": [ + { + "artifactId": "client-location", + "coverage": "absent", + "role": "client" + } + ], + "evidence": [ + { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "finding:policy:assignment:12121212-1212-1212-1212-121212121212:TransferAuth", + "nextArtifacts": [ + { + "logicalId": "clientLocation", + "reason": "Collect the complete ClientLocation.log file.", + "role": "client" + } + ], + "phase": "policy", + "role": "client", + "severity": "Error", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + } + ], + "title": "Client policy workflow evidence" + } + ], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "confirmedFailure", + "condition": "transferAuthenticationFailure", + "confidence": "moderate", + "correlationKeys": [ + { + "confidence": "exact", + "end": 154, + "evidence": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "12121212-1212-1212-1212-121212121212", + "raw": "{12121212-1212-1212-1212-121212121212}", + "start": 116 + }, + { + "confidence": "exact", + "end": 202, + "evidence": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "abababab-abab-abab-abab-abababababab", + "raw": "{abababab-abab-abab-abab-abababababab}", + "start": 164 + }, + { + "confidence": "exact", + "end": 251, + "evidence": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27121212-1212-1212-1212-121212121212", + "raw": "{27121212-1212-1212-1212-121212121212}", + "start": 213 + }, + { + "confidence": "exact", + "end": 299, + "evidence": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 296 + } + ], + "coverageGaps": [ + { + "artifactId": "client-location", + "coverage": "absent", + "role": "client" + } + ], + "evidence": [ + { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "12121212-1212-1212-1212-121212121212", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "abababab-abab-abab-abab-abababababab", + "requestId": "27121212-1212-1212-1212-121212121212" + }, + "lastConfirmedPhase": null, + "nextArtifacts": [ + { + "logicalId": "clientLocation", + "reason": "Collect the complete ClientLocation.log file.", + "role": "client" + } + ], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-auth-agent-current", + "entryId": "fixture-policy-auth-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-auth-agent-current:1-1:TransferAuth:Failed", + "outcome": "failed", + "phase": "transferAuth", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 02:00:00.000", + "utcMillis": 1785376800000 + } + } + ], + "phase": "transferAuth", + "state": "failed", + "transactionId": "policy:assignment:12121212-1212-1212-1212-121212121212" + } + ], + "workflow": "policyAndAssignment" + }, + "rotation-split": { + "artifactRequests": [ + { + "logicalId": "policyAgent", + "reason": "Collect the complete PolicyAgent.log file.", + "role": "client" + } + ], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-rotation-current", + "fixture-policy-rotation-lo" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [], + "logicalArtifactId": "client-policy-state", + "state": "absent" + } + ], + "crossSourceCorrelationPerformed": false, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [], + "profileGaps": [], + "sourceLocalObservations": [ + { + "artifactIds": [ + "fixture-policy-rotation-current", + "fixture-policy-rotation-lo" + ], + "classification": "insufficientEvidence", + "condition": "rotationSplit", + "confidence": "low", + "correlationEligible": false, + "evidence": [], + "nextArtifacts": [ + { + "logicalId": "policyAgent", + "reason": "Collect the complete PolicyAgent.log file.", + "role": "client" + } + ], + "observationId": "policy-source:rotation:fixture-policy-rotation-current+fixture-policy-rotation-lo:synthetic:policy-rotation-boundary", + "state": "incomplete" + } + ], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [], + "workflow": "policyAndAssignment" + }, + "scheduler-deferred": { + "artifactRequests": [ + { + "logicalId": "scheduler", + "reason": "Collect the complete Scheduler.log file.", + "role": "client" + } + ], + "coverage": [ + { + "artifactIds": [ + "fixture-policy-deferred-agent-current", + "fixture-policy-deferred-scheduler-current" + ], + "logicalArtifactId": "client-policy-agent", + "state": "captured" + }, + { + "artifactIds": [], + "logicalArtifactId": "client-policy-state", + "state": "absent" + } + ], + "crossSourceCorrelationPerformed": false, + "extractionProfile": { + "profileId": "policy-client-5.00.test-v1", + "selectionState": "selected", + "syntheticFixtureOnly": true + }, + "findings": [ + { + "class": "blockedOrDeferred", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 139, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "15151515-1515-1515-1515-151515151515", + "raw": "{15151515-1515-1515-1515-151515151515}", + "start": 101 + }, + { + "confidence": "exact", + "end": 187, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae", + "raw": "{aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae}", + "start": 149 + }, + { + "confidence": "exact", + "end": 284, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 281 + }, + { + "confidence": "exact", + "end": 236, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27151515-1515-1515-1515-151515151515", + "raw": "{27151515-1515-1515-1515-151515151515}", + "start": 198 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "finding:policy:assignment:15151515-1515-1515-1515-151515151515:Request", + "nextArtifacts": [ + { + "logicalId": "scheduler", + "reason": "Collect the complete Scheduler.log file.", + "role": "client" + } + ], + "phase": "policy", + "role": "client", + "severity": "Warning", + "summary": "The client policy result is bounded to sealed client-side CCM evidence; no management-point or application outcome is inferred.", + "terminalEvidence": [], + "title": "Client policy workflow evidence" + } + ], + "profileGaps": [], + "sourceLocalObservations": [], + "stateChain": [ + "request", + "download", + "transferAuth", + "persist", + "schedule", + "evaluate", + "report" + ], + "timeOnlyCausalityAllowed": false, + "transactions": [ + { + "classification": "blockedOrDeferred", + "condition": "schedulerBlocked", + "confidence": "high", + "correlationKeys": [ + { + "confidence": "exact", + "end": 139, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "15151515-1515-1515-1515-151515151515", + "raw": "{15151515-1515-1515-1515-151515151515}", + "start": 101 + }, + { + "confidence": "exact", + "end": 95, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "15151515-1515-1515-1515-151515151515", + "raw": "{15151515-1515-1515-1515-151515151515}", + "start": 57 + }, + { + "confidence": "exact", + "end": 94, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "15151515-1515-1515-1515-151515151515", + "raw": "{15151515-1515-1515-1515-151515151515}", + "start": 56 + }, + { + "confidence": "exact", + "end": 152, + "evidence": { + "artifactId": "fixture-policy-deferred-scheduler-current", + "entryId": "fixture-policy-deferred-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "assignmentId", + "normalized": "15151515-1515-1515-1515-151515151515", + "raw": "{15151515-1515-1515-1515-151515151515}", + "start": 114 + }, + { + "confidence": "exact", + "end": 187, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae", + "raw": "{aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae}", + "start": 149 + }, + { + "confidence": "exact", + "end": 143, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae", + "raw": "{aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae}", + "start": 105 + }, + { + "confidence": "exact", + "end": 142, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae", + "raw": "{aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae}", + "start": 104 + }, + { + "confidence": "exact", + "end": 200, + "evidence": { + "artifactId": "fixture-policy-deferred-scheduler-current", + "entryId": "fixture-policy-deferred-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "policyId", + "normalized": "aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae", + "raw": "{aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae}", + "start": 162 + }, + { + "confidence": "exact", + "end": 236, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "requestId", + "normalized": "27151515-1515-1515-1515-151515151515", + "raw": "{27151515-1515-1515-1515-151515151515}", + "start": 198 + }, + { + "confidence": "exact", + "end": 284, + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "extractionProfileId": "policy-client-5.00.test-v1", + "kind": "siteCode", + "normalized": "LAB", + "raw": "LAB", + "start": 281 + } + ], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "fixture-policy-deferred-scheduler-current", + "entryId": "fixture-policy-deferred-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "assignmentId": "15151515-1515-1515-1515-151515151515", + "extractionProfileId": "policy-client-5.00.test-v1", + "policyId": "aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae", + "requestId": "27151515-1515-1515-1515-151515151515" + }, + "lastConfirmedPhase": null, + "nextArtifacts": [ + { + "logicalId": "scheduler", + "reason": "Collect the complete Scheduler.log file.", + "role": "client" + } + ], + "observations": [ + { + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-deferred-agent-current:1-1:Request:Deferred", + "outcome": "deferred", + "phase": "request", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 05:00:00.000", + "utcMillis": 1785387600000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "observationId": "policy-observation:fixture-policy-deferred-agent-current:2-2:Download:Succeeded", + "outcome": "succeeded", + "phase": "download", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 05:00:01.000", + "utcMillis": 1785387601000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-deferred-agent-current", + "entryId": "fixture-policy-deferred-agent-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "observationId": "policy-observation:fixture-policy-deferred-agent-current:3-3:Persist:Succeeded", + "outcome": "succeeded", + "phase": "persist", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 05:00:02.000", + "utcMillis": 1785387602000 + } + }, + { + "evidence": { + "artifactId": "fixture-policy-deferred-scheduler-current", + "entryId": "fixture-policy-deferred-scheduler-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "observationId": "policy-observation:fixture-policy-deferred-scheduler-current:1-1:Schedule:Deferred", + "outcome": "deferred", + "phase": "schedule", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "7-30-2026 05:00:03.000", + "utcMillis": 1785387603000 + } + } + ], + "phase": "request", + "state": "deferred", + "transactionId": "policy:assignment:15151515-1515-1515-1515-151515151515" + } + ], + "workflow": "policyAndAssignment" + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..5eb483518 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-agent/current/Scheduler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-agent/current/Scheduler.log new file mode 100644 index 000000000..5d07288e5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-agent/current/Scheduler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-state/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-state/current/CIAgent.log new file mode 100644 index 000000000..a2776f479 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-state/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-state/current/StateMessage.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-state/current/StateMessage.log new file mode 100644 index 000000000..716faf586 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-state/current/StateMessage.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/expected.json new file mode 100644 index 000000000..8ba14c742 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/expected.json @@ -0,0 +1,47 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "recovery", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent","client-policy-state"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"},{"logicalArtifactId":"client-policy-state","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"policy-recovery-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-recovery-scheduler-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-recovery-evaluate-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-recovery-report-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [{ + "transactionId": "policy:assignment:23232323-2323-2323-2323-232323232323", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"23232323-2323-2323-2323-232323232323","policyId":"b5b5b5b5-b5b5-b5b5-b5b5-b5b5b5b5b5b5","requestId":"27232323-2323-2323-2323-232323232323","clientHandle":"safe:client:policy-23","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-recovery-agent-current","startLine":1,"endLine":1}}, + "phase": "report", + "state": "succeeded", + "lastSuccessfulPhase": "report", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"policy-recovery-agent-current","startLine":1,"endLine":4}, + {"artifactId":"policy-recovery-evaluate-current","startLine":1,"endLine":1}, + {"artifactId":"policy-recovery-report-current","startLine":1,"endLine":1}, + {"artifactId":"policy-recovery-scheduler-current","startLine":1,"endLine":1} + ] + }], + "sourceLocalObservations": [], + "findings": [], + "recovery": { + "phase": "download", + "sameExactKey": true, + "laterTerminalSuccess": true, + "recoveryProven": true, + "failureEvidence": {"artifactId":"policy-recovery-agent-current","startLine":2,"endLine":2}, + "successEvidence": {"artifactId":"policy-recovery-agent-current","startLine":3,"endLine":3} + }, + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/manifest.json new file mode 100644 index 000000000..0dea7be98 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-recovery-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-recovery-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T12:00:10Z","bytesCopied":1223,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"policy-recovery-scheduler-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"Scheduler.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/Scheduler.log","pathFingerprint":"synthetic:policy-recovery-scheduler","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T12:00:10Z","bytesCopied":285,"relativePath":"evidence/client-policy-agent/current/Scheduler.log"}, + {"artifactId":"policy-recovery-evaluate-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic:policy-recovery-evaluate","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T12:00:10Z","bytesCopied":283,"relativePath":"evidence/client-policy-state/current/CIAgent.log"}, + {"artifactId":"policy-recovery-report-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"StateMessage.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/StateMessage.log","pathFingerprint":"synthetic:policy-recovery-report","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T12:00:10Z","bytesCopied":286,"relativePath":"evidence/client-policy-state/current/StateMessage.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..cae721570 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-agent/current/Scheduler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-agent/current/Scheduler.log new file mode 100644 index 000000000..7bfc4865b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-agent/current/Scheduler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-state/current/CIAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-state/current/CIAgent.log new file mode 100644 index 000000000..fbfab09f5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-state/current/CIAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-state/current/StateMessage.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-state/current/StateMessage.log new file mode 100644 index 000000000..92ea46914 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-state/current/StateMessage.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/expected.json new file mode 100644 index 000000000..3b2ce75ec --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/expected.json @@ -0,0 +1,49 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "reporting-failure", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent","client-policy-state"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"},{"logicalArtifactId":"client-policy-state","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"policy-reporting-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-reporting-scheduler-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-reporting-evaluate-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-reporting-state-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [{ + "transactionId": "policy:assignment:17171717-1717-1717-1717-171717171717", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"17171717-1717-1717-1717-171717171717","policyId":"b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0","requestId":"27171717-1717-1717-1717-171717171717","clientHandle":"safe:client:policy-17","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-reporting-agent-current","startLine":1,"endLine":1}}, + "phase": "report", + "state": "failed", + "lastSuccessfulPhase": "evaluate", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"policy-reporting-agent-current","startLine":1,"endLine":3}, + {"artifactId":"policy-reporting-evaluate-current","startLine":1,"endLine":1}, + {"artifactId":"policy-reporting-scheduler-current","startLine":1,"endLine":1}, + {"artifactId":"policy-reporting-state-current","startLine":1,"endLine":1} + ] + }], + "sourceLocalObservations": [], + "findings": [{ + "findingId": "finding:policy-reporting-failure", + "subjectId": "policy:assignment:17171717-1717-1717-1717-171717171717", + "class": "confirmedFailure", + "phase": "report", + "lastSuccessfulPhase": "evaluate", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [{"artifactId":"policy-reporting-state-current","startLine":1,"endLine":1}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/manifest.json new file mode 100644 index 000000000..5d62878b1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-reporting-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-reporting-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T07:00:06Z","bytesCopied":947,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"policy-reporting-scheduler-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"Scheduler.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/Scheduler.log","pathFingerprint":"synthetic:policy-reporting-scheduler","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T07:00:06Z","bytesCopied":294,"relativePath":"evidence/client-policy-agent/current/Scheduler.log"}, + {"artifactId":"policy-reporting-evaluate-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"CIAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/CIAgent.log","pathFingerprint":"synthetic:policy-reporting-evaluate","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T07:00:06Z","bytesCopied":292,"relativePath":"evidence/client-policy-state/current/CIAgent.log"}, + {"artifactId":"policy-reporting-state-current","designOnlyCatalog":{"entryId":"client-policy-state","groupMemberships":["client-policy-state"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"StateMessage.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/StateMessage.log","pathFingerprint":"synthetic:policy-reporting-state","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T07:00:06Z","bytesCopied":319,"relativePath":"evidence/client-policy-state/current/StateMessage.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..2c5146f45 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/expected.json new file mode 100644 index 000000000..58f5042cd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/expected.json @@ -0,0 +1,39 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "request-auth-failure", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-location","state":"absent"},{"logicalArtifactId":"client-policy-agent","state":"captured"}], + "artifactProvenance": [{"artifactId":"policy-auth-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}], + "transactions": [{ + "transactionId": "policy:assignment:12121212-1212-1212-1212-121212121212", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"12121212-1212-1212-1212-121212121212","policyId":"abababab-abab-abab-abab-abababababab","requestId":"27121212-1212-1212-1212-121212121212","clientHandle":"safe:client:policy-12","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-auth-agent-current","startLine":1,"endLine":1}}, + "phase": "request", + "state": "failed", + "lastSuccessfulPhase": null, + "classification": "confirmedFailure", + "confidence": "medium", + "confidenceCeiling": "medium", + "coverageGapArtifactIds": ["client-location"], + "nextArtifact": {"logicalArtifactId":"client-location","reason":"Capture bounded client-side location and transport context; do not infer an MP cause."}, + "evidence": [{"artifactId":"policy-auth-agent-current","startLine":1,"endLine":1}] + }], + "sourceLocalObservations": [], + "findings": [{ + "findingId": "finding:policy-request-auth-failure", + "subjectId": "policy:assignment:12121212-1212-1212-1212-121212121212", + "class": "confirmedFailure", + "phase": "request", + "lastSuccessfulPhase": null, + "confidence": "medium", + "confidenceCeiling": "medium", + "nextArtifact": {"logicalArtifactId":"client-location","reason":"Capture bounded client-side location and transport context; do not infer an MP cause."}, + "evidence": [{"artifactId":"policy-auth-agent-current","startLine":1,"endLine":1}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/manifest.json new file mode 100644 index 000000000..36adb1143 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-auth-location-absent","designOnlyCatalog":{"entryId":"client-location","groupMemberships":["client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","originalBasename":"ClientLocation.log","sanitizedSourcePath":null,"pathFingerprint":null,"rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":null,"capturedUtc":"2026-07-30T02:00:01Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"policy-auth-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-auth-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:01Z","bytesCopied":478,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/rotation-split/evidence/client-policy-agent/current/PolicyAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/rotation-split/evidence/client-policy-agent/current/PolicyAgent.log new file mode 100644 index 000000000..c0e33021f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/rotation-split/evidence/client-policy-agent/current/PolicyAgent.log @@ -0,0 +1 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/evidence/client-policy-agent/current/Scheduler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/evidence/client-policy-agent/current/Scheduler.log new file mode 100644 index 000000000..0e618d5ac --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/evidence/client-policy-agent/current/Scheduler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/expected.json new file mode 100644 index 000000000..9267c0899 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/expected.json @@ -0,0 +1,45 @@ +{ + "contractState": "proposedPending318", + "workflow": "policy", + "scenario": "scheduler-deferred", + "stateChain": ["request","download","persist","schedule","evaluate","report"], + "analysisContract": {"independentReducer":true,"consumesOtherReducerOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"policy-client-5.00.test-v1","sourceVersionPrefix":"5.00.TEST.","keyKinds":["assignmentId","policyId","requestId","clientSafeHandle","siteCode","managementPointHostHandle"],"validatedArtifactFamilies":["client-policy-agent"]}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"captured"}], + "artifactProvenance": [ + {"artifactId":"policy-deferred-agent-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"policy-deferred-scheduler-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "transactions": [{ + "transactionId": "policy:assignment:15151515-1515-1515-1515-151515151515", + "key": {"keyProfileKind":"requestPolicyClientTopology","assignmentId":"15151515-1515-1515-1515-151515151515","policyId":"aeaeaeae-aeae-aeae-aeae-aeaeaeaeaeae","requestId":"27151515-1515-1515-1515-151515151515","clientHandle":"safe:client:policy-15","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","managementPointEvidenceKind":"selected","confidence":"exact","extractionProfileId":"policy-client-5.00.test-v1"}, + "counterpartReadyFact": {"phase":"request","extractionProfileId":"policy-client-5.00.test-v1","evidence":{"artifactId":"policy-deferred-agent-current","startLine":1,"endLine":1}}, + "phase": "schedule", + "state": "deferred", + "lastSuccessfulPhase": "persist", + "classification": "blockedOrDeferred", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-policy-agent","reason":"Capture the bounded scheduler continuation after the retry window."}, + "evidence": [ + {"artifactId":"policy-deferred-agent-current","startLine":1,"endLine":3}, + {"artifactId":"policy-deferred-scheduler-current","startLine":1,"endLine":1} + ] + }], + "sourceLocalObservations": [], + "findings": [{ + "findingId": "finding:policy-scheduler-deferred", + "subjectId": "policy:assignment:15151515-1515-1515-1515-151515151515", + "class": "blockedOrDeferred", + "phase": "schedule", + "lastSuccessfulPhase": "persist", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": {"logicalArtifactId":"client-policy-agent","reason":"Capture the bounded scheduler continuation after the retry window."}, + "evidence": [{"artifactId":"policy-deferred-scheduler-current","startLine":1,"endLine":1}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"topologyMismatchOwner":"#333","bundleCaptureHostUsedAsManagementPointEvidence":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"],"emittedCounterpartReadyFact":true}, + "prohibitedClaims": ["management point or server root cause","cross-side correlation or #333 conclusion","device-wide transaction merging"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/manifest.json new file mode 100644 index 000000000..d164baf19 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"role":"client","workflow":"policy","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"policy-deferred-agent-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"PolicyAgent.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/PolicyAgent.log","pathFingerprint":"synthetic:policy-deferred-agent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T05:00:04Z","bytesCopied":948,"relativePath":"evidence/client-policy-agent/current/PolicyAgent.log"}, + {"artifactId":"policy-deferred-scheduler-current","designOnlyCatalog":{"entryId":"client-policy-agent","groupMemberships":["client-policy-agent"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"Scheduler.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/Scheduler.log","pathFingerprint":"synthetic:policy-deferred-scheduler","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T05:00:04Z","bytesCopied":307,"relativePath":"evidence/client-policy-agent/current/Scheduler.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/evidence/client-task-sequence-smsts/full-os/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/evidence/client-task-sequence-smsts/full-os/current/smsts.log new file mode 100644 index 000000000..b9b5c900a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/evidence/client-task-sequence-smsts/full-os/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/expected.json new file mode 100644 index 000000000..a2d636ade --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "client-install-failure", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["fullOs"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-client-install-failure-smsts-current","bytesCopied":443,"pathClass":"fullOs","sanitizedSourcePath":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-012", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000012","taskSequencePackageId":"LAB00324","advertisementId":"LAB20312","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-client-install-failure-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-client-install-failure-smsts-current","pathClass":"fullOs","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:42:00Z"}, + "orderingEvidence": {"artifactId":"task-sequence-client-install-failure-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": null, + "phase": "installClient", + "state": "failed", + "lastSuccessfulPhase": "setupWindows", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "terminalEvidence": {"artifactId":"task-sequence-client-install-failure-smsts-current","startLine":1,"endLine":1} + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"client-install-terminal-failure","classification":"confirmedFailure","evidence":[{"artifactId":"task-sequence-client-install-failure-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/manifest.json new file mode 100644 index 000000000..d746de5ef --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "client-install-failure", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-client-install-failure-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","pathFingerprint":"synthetic:client-install-failure:full-os","pathClass":"fullOs","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:42:05Z","bytesCopied":443,"relativePath":"evidence/client-task-sequence-smsts/full-os/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/evidence/client-task-sequence-smsts/client/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/evidence/client-task-sequence-smsts/client/current/smsts.log new file mode 100644 index 000000000..e80653417 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/evidence/client-task-sequence-smsts/client/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/expected.json new file mode 100644 index 000000000..95f868b01 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "client-installed", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["client"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-client-installed-smsts-current","bytesCopied":414,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-004", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000004","taskSequencePackageId":"LAB00324","advertisementId":"LAB20304","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-client-installed-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-client-installed-smsts-current","pathClass":"client","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:03:01Z"}, + "orderingEvidence": {"artifactId":"task-sequence-client-installed-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-task-sequence-smsts","pathClass":"client","reason":"Capture the completed client log to confirm a terminal outcome."}, + "phase": "installClient", + "state": "inProgress", + "lastSuccessfulPhase": "setupWindows", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "terminalEvidence": null + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"client-installed-nonterminal","classification":"insufficientEvidence","evidence":[{"artifactId":"task-sequence-client-installed-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/manifest.json new file mode 100644 index 000000000..c2d88f340 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "client-installed", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-client-installed-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","pathFingerprint":"synthetic:client-installed:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:03:06Z","bytesCopied":414,"relativePath":"evidence/client-task-sequence-smsts/client/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/evidence/client-task-sequence-smsts/client/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/evidence/client-task-sequence-smsts/client/current/smsts.log new file mode 100644 index 000000000..76dba1f37 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/evidence/client-task-sequence-smsts/client/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/expected.json new file mode 100644 index 000000000..d92cb18ea --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/expected.json @@ -0,0 +1,21 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "complete-looking-unkeyed", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["client"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-complete-looking-unkeyed-smsts-current","bytesCopied":318,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [], + "sourceLocalObservations": [ + {"observationId":"complete-looking-unkeyed-source-local","artifactId":"task-sequence-complete-looking-unkeyed-smsts-current","keyConfidence":"none","confidence":"low","confidenceCeiling":"low","correlationEligible":false,"phaseHint":"complete","stateHint":"succeeded","evidence":{"artifactId":"task-sequence-complete-looking-unkeyed-smsts-current","startLine":1,"endLine":1},"reason":"A filename, path, timestamp, or display name cannot substitute for the missing exact execution key."} + ], + "findings": [ + {"findingId":"complete-looking-unkeyed-insufficient","classification":"insufficientEvidence","evidence":[{"artifactId":"task-sequence-complete-looking-unkeyed-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"sourceLocalOnly","joinFields":[],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/manifest.json new file mode 100644 index 000000000..e061c7e7b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "complete-looking-unkeyed", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-complete-looking-unkeyed-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","pathFingerprint":"synthetic:complete-looking-unkeyed:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:47:05Z","bytesCopied":318,"relativePath":"evidence/client-task-sequence-smsts/client/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/evidence/client-task-sequence-smsts/client/completed/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/evidence/client-task-sequence-smsts/client/completed/smsts.log new file mode 100644 index 000000000..6791fce25 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/evidence/client-task-sequence-smsts/client/completed/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/expected.json new file mode 100644 index 000000000..777345285 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/expected.json @@ -0,0 +1,35 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "completed", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["client"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-completed-smsts-current","bytesCopied":391,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-005", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000005","taskSequencePackageId":"LAB00324","advertisementId":"LAB20305","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-completed-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-completed-smsts-current","pathClass":"client","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:04:01Z"}, + "orderingEvidence": {"artifactId":"task-sequence-completed-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": null, + "phase": "complete", + "state": "succeeded", + "lastSuccessfulPhase": "complete", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "terminalEvidence": {"artifactId":"task-sequence-completed-smsts-current","startLine":1,"endLine":1} + } + ], + "sourceLocalObservations": [], + "findings": [], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/manifest.json new file mode 100644 index 000000000..3d29e2770 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "completed", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-completed-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","pathFingerprint":"synthetic:completed:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:04:06Z","bytesCopied":391,"relativePath":"evidence/client-task-sequence-smsts/client/completed/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/evidence/client-task-sequence-smsts/setup/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/evidence/client-task-sequence-smsts/setup/current/smsts.log new file mode 100644 index 000000000..cff6f7c5a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/evidence/client-task-sequence-smsts/setup/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/expected.json new file mode 100644 index 000000000..87727b5b0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "disk-image-failure", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["setup"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-disk-image-failure-smsts-current","bytesCopied":419,"pathClass":"setup","sanitizedSourcePath":"SYNTHETIC://setup/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://setup/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-011", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000011","taskSequencePackageId":"LAB00324","advertisementId":"LAB20311","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-disk-image-failure-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-disk-image-failure-smsts-current","pathClass":"setup","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:41:00Z"}, + "orderingEvidence": {"artifactId":"task-sequence-disk-image-failure-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": null, + "phase": "diskOrImage", + "state": "failed", + "lastSuccessfulPhase": "preflight", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "terminalEvidence": {"artifactId":"task-sequence-disk-image-failure-smsts-current","startLine":1,"endLine":1} + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"disk-image-terminal-failure","classification":"confirmedFailure","evidence":[{"artifactId":"task-sequence-disk-image-failure-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/manifest.json new file mode 100644 index 000000000..21ffdd7ee --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "disk-image-failure", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-disk-image-failure-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://setup/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://setup/smstslog/smsts.log","pathFingerprint":"synthetic:disk-image-failure:setup","pathClass":"setup","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:41:05Z","bytesCopied":419,"relativePath":"evidence/client-task-sequence-smsts/setup/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/incomplete/expected.json new file mode 100644 index 000000000..e321b8336 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/incomplete/expected.json @@ -0,0 +1,17 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "incomplete", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":null,"status":"notObserved"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"absent","pathClasses":["unknown"]}], + "artifactProvenance": [], + "transactions": [], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"smsts-coverage-absent","classification":"insufficientEvidence","evidence":[],"coverageGapArtifactIds":["task-sequence-incomplete-smsts-absent"],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false,"boundedNextArtifact":{"logicalArtifactId":"client-task-sequence-smsts","pathClass":"unknown","reason":"Collect smsts evidence from the active task sequence path and report its capture state."}} + ], + "correlationBoundary": {"scope":"coverageOnly","joinFields":[],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/incomplete/manifest.json new file mode 100644 index 000000000..2a88fd776 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/incomplete/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "incomplete", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-incomplete-smsts-absent","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"smsts.log","sanitizedSourcePath":null,"smstsLogPathEvidence":null,"pathFingerprint":"synthetic:incomplete:candidate","pathClass":"unknown","rotation":{"kind":"current"},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:35:00Z","bytesCopied":0,"relativePath":null} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/evidence/client-task-sequence-smsts/client/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/evidence/client-task-sequence-smsts/client/current/smsts.log new file mode 100644 index 000000000..198bb44a9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/evidence/client-task-sequence-smsts/client/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/expected.json new file mode 100644 index 000000000..0e595cc12 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "invalid-offset", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["client"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-invalid-offset-smsts-current","bytesCopied":415,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-015", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000015","taskSequencePackageId":"LAB00324","advertisementId":"LAB20315","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-invalid-offset-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-invalid-offset-smsts-current","pathClass":"client","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"offsetInvalid","offsetMinutes":9999,"normalizedUtc":null}, + "orderingEvidence": {"artifactId":"task-sequence-invalid-offset-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-task-sequence-smsts","pathClass":"client","reason":"Collect a keyed record with a valid CCM offset before ordering this execution."}, + "phase": "installSoftware", + "state": "inProgress", + "lastSuccessfulPhase": "installClient", + "classification": "insufficientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "terminalEvidence": null + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"invalid-offset-ordering-unknown","classification":"insufficientEvidence","evidence":[{"artifactId":"task-sequence-invalid-offset-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnlyOrderingUnknown","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/manifest.json new file mode 100644 index 000000000..8953c45e1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "invalid-offset", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-invalid-offset-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","pathFingerprint":"synthetic:invalid-offset:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:45:05Z","bytesCopied":415,"relativePath":"evidence/client-task-sequence-smsts/client/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/evidence/client-task-sequence-smsts/setup/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/evidence/client-task-sequence-smsts/setup/current/smsts.log new file mode 100644 index 000000000..a6784b46e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/evidence/client-task-sequence-smsts/setup/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/expected.json new file mode 100644 index 000000000..467ddebab --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "post-format", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["setup"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-post-format-smsts-current","bytesCopied":397,"pathClass":"setup","sanitizedSourcePath":"SYNTHETIC://setup/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://setup/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-002", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000002","taskSequencePackageId":"LAB00324","advertisementId":"LAB20302","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-post-format-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-post-format-smsts-current","pathClass":"setup","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:01:01Z"}, + "orderingEvidence": {"artifactId":"task-sequence-post-format-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-task-sequence-smsts","pathClass":"fullOs","reason":"Collect the relocated pre-client fragment to continue the keyed execution."}, + "phase": "diskOrImage", + "state": "inProgress", + "lastSuccessfulPhase": "preflight", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "terminalEvidence": null + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"post-format-nonterminal","classification":"insufficientEvidence","evidence":[{"artifactId":"task-sequence-post-format-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/manifest.json new file mode 100644 index 000000000..02202b5d9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "post-format", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-post-format-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://setup/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://setup/smstslog/smsts.log","pathFingerprint":"synthetic:post-format:setup","pathClass":"setup","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:01:06Z","bytesCopied":397,"relativePath":"evidence/client-task-sequence-smsts/setup/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/evidence/client-task-sequence-smsts/full-os/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/evidence/client-task-sequence-smsts/full-os/current/smsts.log new file mode 100644 index 000000000..90772a0aa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/evidence/client-task-sequence-smsts/full-os/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/expected.json new file mode 100644 index 000000000..576012ca7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "pre-client", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["fullOs"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-pre-client-smsts-current","bytesCopied":428,"pathClass":"fullOs","sanitizedSourcePath":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-003", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000003","taskSequencePackageId":"LAB00324","advertisementId":"LAB20303","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-pre-client-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-pre-client-smsts-current","pathClass":"fullOs","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:02:01Z"}, + "orderingEvidence": {"artifactId":"task-sequence-pre-client-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-task-sequence-smsts","pathClass":"client","reason":"Collect the post-client relocation to determine whether execution continued."}, + "phase": "setupWindows", + "state": "blockedOrDeferred", + "lastSuccessfulPhase": "diskOrImage", + "classification": "blockedOrDeferred", + "confidence": "medium", + "confidenceCeiling": "medium", + "terminalEvidence": null + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"pre-client-deferred","classification":"blockedOrDeferred","evidence":[{"artifactId":"task-sequence-pre-client-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/manifest.json new file mode 100644 index 000000000..e18617a09 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "pre-client", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-pre-client-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","pathFingerprint":"synthetic:pre-client:full-os","pathClass":"fullOs","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:02:06Z","bytesCopied":428,"relativePath":"evidence/client-task-sequence-smsts/full-os/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/evidence/client-task-sequence-smsts/client/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/evidence/client-task-sequence-smsts/client/current/smsts.log new file mode 100644 index 000000000..50e693e1c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/evidence/client-task-sequence-smsts/client/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/expected.json new file mode 100644 index 000000000..038d7782d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "reboot-continuation", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["client"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-reboot-continuation-smsts-current","bytesCopied":468,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-014", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000014","taskSequencePackageId":"LAB00324","advertisementId":"LAB20314","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-reboot-continuation-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-reboot-continuation-smsts-current","pathClass":"client","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:44:00Z"}, + "orderingEvidence": {"artifactId":"task-sequence-reboot-continuation-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-task-sequence-smsts","pathClass":"client","reason":"Collect the resumed client fragment after reboot before declaring failure or completion."}, + "phase": "postAction", + "state": "blockedOrDeferred", + "lastSuccessfulPhase": "installSoftware", + "classification": "blockedOrDeferred", + "confidence": "medium", + "confidenceCeiling": "medium", + "terminalEvidence": null + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"reboot-continuation-deferred","classification":"blockedOrDeferred","evidence":[{"artifactId":"task-sequence-reboot-continuation-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/manifest.json new file mode 100644 index 000000000..51d6056c8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "reboot-continuation", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-reboot-continuation-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","pathFingerprint":"synthetic:reboot-continuation:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:44:05Z","bytesCopied":468,"relativePath":"evidence/client-task-sequence-smsts/client/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/client/completed/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/client/completed/smsts.log new file mode 100644 index 000000000..1ddb93dce --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/client/completed/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/full-os/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/full-os/current/smsts.log new file mode 100644 index 000000000..7830fed85 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/full-os/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/setup/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/setup/current/smsts.log new file mode 100644 index 000000000..254ee3115 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/setup/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/winpe/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/winpe/current/smsts.log new file mode 100644 index 000000000..ffe3ed28b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/winpe/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/expected.json new file mode 100644 index 000000000..cadb51cdc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/expected.json @@ -0,0 +1,48 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "relocated-fragments", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["client","fullOs","setup","winpe"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-relocated-01-winpe","bytesCopied":411,"pathClass":"winpe","sanitizedSourcePath":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0}, + {"artifactId":"task-sequence-relocated-02-setup","bytesCopied":400,"pathClass":"setup","sanitizedSourcePath":"SYNTHETIC://setup/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://setup/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":1}, + {"artifactId":"task-sequence-relocated-03-full-os","bytesCopied":427,"pathClass":"fullOs","sanitizedSourcePath":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":2}, + {"artifactId":"task-sequence-relocated-04-client","bytesCopied":398,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":3} + ], + "transactions": [ + { + "transactionId": "task-sequence-006", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000006","taskSequencePackageId":"LAB00324","advertisementId":"LAB20306","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [ + {"artifactId":"task-sequence-relocated-01-winpe","startLine":1,"endLine":1}, + {"artifactId":"task-sequence-relocated-02-setup","startLine":1,"endLine":1}, + {"artifactId":"task-sequence-relocated-03-full-os","startLine":1,"endLine":1}, + {"artifactId":"task-sequence-relocated-04-client","startLine":1,"endLine":1} + ], + "pathSequence": [ + {"artifactId":"task-sequence-relocated-01-winpe","pathClass":"winpe","relocationOrdinal":0}, + {"artifactId":"task-sequence-relocated-02-setup","pathClass":"setup","relocationOrdinal":1}, + {"artifactId":"task-sequence-relocated-03-full-os","pathClass":"fullOs","relocationOrdinal":2}, + {"artifactId":"task-sequence-relocated-04-client","pathClass":"client","relocationOrdinal":3} + ], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:10:03Z"}, + "orderingEvidence": {"artifactId":"task-sequence-relocated-04-client","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": null, + "phase": "complete", + "state": "succeeded", + "lastSuccessfulPhase": "complete", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "terminalEvidence": {"artifactId":"task-sequence-relocated-04-client","startLine":1,"endLine":1} + } + ], + "sourceLocalObservations": [], + "findings": [], + "correlationBoundary": {"scope":"clientRelocationOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"],"pathOrderSource":"_SMSTSLogPath plus explicit relocationOrdinal"} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/manifest.json new file mode 100644 index 000000000..697fe987a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/manifest.json @@ -0,0 +1,13 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "relocated-fragments", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-relocated-01-winpe","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","pathFingerprint":"synthetic:relocated:winpe","pathClass":"winpe","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:05Z","bytesCopied":411,"relativePath":"evidence/client-task-sequence-smsts/winpe/current/smsts.log"}, + {"artifactId":"task-sequence-relocated-02-setup","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://setup/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://setup/smstslog/smsts.log","pathFingerprint":"synthetic:relocated:setup","pathClass":"setup","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":1,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:06Z","bytesCopied":400,"relativePath":"evidence/client-task-sequence-smsts/setup/current/smsts.log"}, + {"artifactId":"task-sequence-relocated-03-full-os","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://full-os/_SMSTaskSequence/Logs/smstslog/smsts.log","pathFingerprint":"synthetic:relocated:full-os","pathClass":"fullOs","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":2,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:07Z","bytesCopied":427,"relativePath":"evidence/client-task-sequence-smsts/full-os/current/smsts.log"}, + {"artifactId":"task-sequence-relocated-04-client","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","pathFingerprint":"synthetic:relocated:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":3,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:08Z","bytesCopied":398,"relativePath":"evidence/client-task-sequence-smsts/client/completed/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/evidence/client-task-sequence-smsts/client/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/evidence/client-task-sequence-smsts/client/current/smsts.log new file mode 100644 index 000000000..b9db029ac --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/evidence/client-task-sequence-smsts/client/current/smsts.log @@ -0,0 +1 @@ + terminal=false]LOG]!> diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/evidence/client-task-sequence-smsts/client/lo/smsts.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/evidence/client-task-sequence-smsts/client/lo/smsts.lo_ new file mode 100644 index 000000000..289ddfedc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/evidence/client-task-sequence-smsts/client/lo/smsts.lo_ @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/software-install-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/software-install-failure/expected.json new file mode 100644 index 000000000..11eede7fd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/software-install-failure/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "software-install-failure", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["client"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-software-install-failure-smsts-current","bytesCopied":439,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-013", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000013","taskSequencePackageId":"LAB00324","advertisementId":"LAB20313","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-software-install-failure-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-software-install-failure-smsts-current","pathClass":"client","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:43:00Z"}, + "orderingEvidence": {"artifactId":"task-sequence-software-install-failure-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": null, + "phase": "installSoftware", + "state": "failed", + "lastSuccessfulPhase": "installClient", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "terminalEvidence": {"artifactId":"task-sequence-software-install-failure-smsts-current","startLine":1,"endLine":1} + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"software-install-terminal-failure","classification":"confirmedFailure","evidence":[{"artifactId":"task-sequence-software-install-failure-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"taskSequenceOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"],"causeBoundary":"No application, policy, or server causality is inferred from this task sequence record."} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/software-install-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/software-install-failure/manifest.json new file mode 100644 index 000000000..0878aef98 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/software-install-failure/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "software-install-failure", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-software-install-failure-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smstslog/smsts.log","pathFingerprint":"synthetic:software-install-failure:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:43:05Z","bytesCopied":439,"relativePath":"evidence/client-task-sequence-smsts/client/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/evidence/client-task-sequence-smsts/winpe/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/evidence/client-task-sequence-smsts/winpe/current/smsts.log new file mode 100644 index 000000000..8edf6a02d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/evidence/client-task-sequence-smsts/winpe/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/expected.json new file mode 100644 index 000000000..406b621f3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "terminal-preflight", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["winpe"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-terminal-preflight-smsts-current","bytesCopied":430,"pathClass":"winpe","sanitizedSourcePath":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-010", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000010","taskSequencePackageId":"LAB00324","advertisementId":"LAB20310","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-terminal-preflight-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-terminal-preflight-smsts-current","pathClass":"winpe","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:40:00Z"}, + "orderingEvidence": {"artifactId":"task-sequence-terminal-preflight-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": null, + "phase": "preflight", + "state": "failed", + "lastSuccessfulPhase": "start", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "terminalEvidence": {"artifactId":"task-sequence-terminal-preflight-smsts-current","startLine":1,"endLine":1} + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"preflight-terminal-failure","classification":"confirmedFailure","evidence":[{"artifactId":"task-sequence-terminal-preflight-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/manifest.json new file mode 100644 index 000000000..8a798fde8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "terminal-preflight", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-terminal-preflight-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","pathFingerprint":"synthetic:terminal-preflight:winpe","pathClass":"winpe","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:05Z","bytesCopied":430,"relativePath":"evidence/client-task-sequence-smsts/winpe/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/evidence/client-task-sequence-smsts/unknown/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/evidence/client-task-sequence-smsts/unknown/current/smsts.log new file mode 100644 index 000000000..ec93248da --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/evidence/client-task-sequence-smsts/unknown/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/expected.json new file mode 100644 index 000000000..c102800ae --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/expected.json @@ -0,0 +1,21 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "unknown-profile", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":null,"status":"unknownVersionRejected"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["unknown"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-unknown-profile-smsts-current","bytesCopied":401,"pathClass":"unknown","sanitizedSourcePath":"SYNTHETIC://unknown/observed/smsts.log","smstsLogPathEvidence":"SYNTHETIC://unknown/observed/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [], + "sourceLocalObservations": [ + {"observationId":"unknown-profile-source-local","artifactId":"task-sequence-unknown-profile-smsts-current","keyConfidence":"candidate","confidence":"low","confidenceCeiling":"low","correlationEligible":false,"phaseHint":"preflight","stateHint":"inProgress","evidence":{"artifactId":"task-sequence-unknown-profile-smsts-current","startLine":1,"endLine":1},"reason":"Key-looking fields from an unrecognized source version cannot be promoted by an unverified extraction profile."} + ], + "findings": [ + {"findingId":"unknown-profile-insufficient","classification":"insufficientEvidence","evidence":[{"artifactId":"task-sequence-unknown-profile-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false,"boundedNextArtifact":{"logicalArtifactId":"client-task-sequence-smsts","pathClass":"unknown","reason":"Add a reviewed extraction profile for the observed version before correlation."}} + ], + "correlationBoundary": {"scope":"sourceLocalOnly","joinFields":[],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/manifest.json new file mode 100644 index 000000000..79db9ce53 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "unknown-profile", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-unknown-profile-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://unknown/observed/smsts.log","smstsLogPathEvidence":"SYNTHETIC://unknown/observed/smsts.log","pathFingerprint":"synthetic:unknown-profile:unknown","pathClass":"unknown","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.UNKNOWN.0000","capturedUtc":"2026-07-30T01:46:05Z","bytesCopied":401,"relativePath":"evidence/client-task-sequence-smsts/unknown/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/evidence/client-task-sequence-smsts/client/root-a/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/evidence/client-task-sequence-smsts/client/root-a/current/smsts.log new file mode 100644 index 000000000..94b5cb88f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/evidence/client-task-sequence-smsts/client/root-a/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/evidence/client-task-sequence-smsts/client/root-b/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/evidence/client-task-sequence-smsts/client/root-b/current/smsts.log new file mode 100644 index 000000000..8f6b0da4a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/evidence/client-task-sequence-smsts/client/root-b/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/expected.json new file mode 100644 index 000000000..7e9e169b8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/expected.json @@ -0,0 +1,56 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "unrelated-runs", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["client"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-unrelated-run-a","bytesCopied":424,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/root-a/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/root-a/CCM/Logs/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0}, + {"artifactId":"task-sequence-unrelated-run-b","bytesCopied":418,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/root-b/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/root-b/CCM/Logs/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-007", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000007","taskSequencePackageId":"LAB00324","advertisementId":"LAB20307","runContext":"osd-a","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-unrelated-run-a","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-unrelated-run-a","pathClass":"client","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:20:00Z"}, + "orderingEvidence": {"artifactId":"task-sequence-unrelated-run-a","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-task-sequence-smsts","pathClass":"client","reason":"Collect later records bearing execution 007's exact key."}, + "phase": "installSoftware", + "state": "inProgress", + "lastSuccessfulPhase": "installClient", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "terminalEvidence": null + }, + { + "transactionId": "task-sequence-008", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000008","taskSequencePackageId":"LAB00324","advertisementId":"LAB20308","runContext":"osd-b","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-unrelated-run-b","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-unrelated-run-b","pathClass":"client","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:20:00Z"}, + "orderingEvidence": {"artifactId":"task-sequence-unrelated-run-b","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-task-sequence-smsts","pathClass":"client","reason":"Collect later records bearing execution 008's exact key."}, + "phase": "preflight", + "state": "inProgress", + "lastSuccessfulPhase": "start", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "terminalEvidence": null + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"run-a-nonterminal","classification":"insufficientEvidence","evidence":[{"artifactId":"task-sequence-unrelated-run-a","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false}, + {"findingId":"run-b-nonterminal","classification":"insufficientEvidence","evidence":[{"artifactId":"task-sequence-unrelated-run-b","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"],"sameTimestampDoesNotJoin":true} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/manifest.json new file mode 100644 index 000000000..38ad4e47a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "unrelated-runs", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-unrelated-run-a","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://client/root-a/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/root-a/CCM/Logs/smstslog/smsts.log","pathFingerprint":"synthetic:unrelated:root-a","pathClass":"client","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:05Z","bytesCopied":424,"relativePath":"evidence/client-task-sequence-smsts/client/root-a/current/smsts.log"}, + {"artifactId":"task-sequence-unrelated-run-b","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://client/root-b/CCM/Logs/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/root-b/CCM/Logs/smstslog/smsts.log","pathFingerprint":"synthetic:unrelated:root-b","pathClass":"client","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:05Z","bytesCopied":418,"relativePath":"evidence/client-task-sequence-smsts/client/root-b/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/evidence/client-task-sequence-smsts/winpe/current/smsts.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/evidence/client-task-sequence-smsts/winpe/current/smsts.log new file mode 100644 index 000000000..cfa46cd55 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/evidence/client-task-sequence-smsts/winpe/current/smsts.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/expected.json new file mode 100644 index 000000000..b629295d7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPending318And319", + "workflow": "taskSequence", + "scenario": "winpe", + "stateChain": ["start","preflight","diskOrImage","setupWindows","installClient","installSoftware","postAction","complete"], + "analysisContract": {"independentReducer":true,"consumesAppOrPolicyReducerOutput":false,"crossSideCorrelationPerformed":false,"nativeAcceptanceClaimed":false}, + "extractionProfile": {"id":"task-sequence-client-5.00.test-v1","status":"matched"}, + "reorderedInputDeterministic": true, + "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"captured","pathClasses":["winpe"]}], + "artifactProvenance": [ + {"artifactId":"task-sequence-winpe-smsts-current","bytesCopied":402,"pathClass":"winpe","sanitizedSourcePath":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","rotationKind":"current","fragmentComplete":true,"relocationOrdinal":0} + ], + "transactions": [ + { + "transactionId": "task-sequence-001", + "key": {"keyProfileKind":"executionPackageAdvertisementContext","executionId":"72400000-0000-0000-0000-000000000001","taskSequencePackageId":"LAB00324","advertisementId":"LAB20301","runContext":"osd","confidence":"exact","extractionProfileId":"task-sequence-client-5.00.test-v1"}, + "evidence": [{"artifactId":"task-sequence-winpe-smsts-current","startLine":1,"endLine":1}], + "pathSequence": [{"artifactId":"task-sequence-winpe-smsts-current","pathClass":"winpe","relocationOrdinal":0}], + "timestampProvenance": {"orderingState":"normalizedUtc","offsetMinutes":0,"normalizedUtc":"2026-07-30T01:00:01Z"}, + "orderingEvidence": {"artifactId":"task-sequence-winpe-smsts-current","startLine":1,"endLine":1}, + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"client-task-sequence-smsts","pathClass":"setup","reason":"Collect the post-format relocation to continue the keyed execution."}, + "phase": "preflight", + "state": "inProgress", + "lastSuccessfulPhase": "start", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "terminalEvidence": null + } + ], + "sourceLocalObservations": [], + "findings": [ + {"findingId":"winpe-nonterminal","classification":"insufficientEvidence","evidence":[{"artifactId":"task-sequence-winpe-smsts-current","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false} + ], + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/manifest.json new file mode 100644 index 000000000..b4249c812 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/manifest.json @@ -0,0 +1,10 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "winpe", + "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, + "artifacts": [ + {"artifactId":"task-sequence-winpe-smsts-current","designOnlyCatalog":{"entryId":"client-task-sequence-smsts","groupMemberships":["client-task-sequence-smsts"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"smsts.log","sanitizedSourcePath":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","smstsLogPathEvidence":"SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log","pathFingerprint":"synthetic:winpe:preformat","pathClass":"winpe","rotation":{"kind":"current","fragmentComplete":true},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:06Z","bytesCopied":402,"relativePath":"evidence/client-task-sequence-smsts/winpe/current/smsts.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md new file mode 100644 index 000000000..372602846 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md @@ -0,0 +1,83 @@ +# Synthetic SCCM client software-update corpus + +This directory is the issue #323 preparation corpus for the client-side +software-update workflow. It contains synthetic evidence contracts only. It +does not implement an update reducer, native source discovery, server SUP +health analysis, or cross-side correlation. + +Every scenario contains: + +- `manifest.json`: additive SCCM proposal metadata for every expected physical + source and coverage-only source; +- `evidence/`: only the bounded files referenced by captured/capped manifest + entries; and +- `expected.json`: proposed behavior labels for the future #318/#319-backed + reducer. + +`contractState: proposedPending318` means the expected-output field names are +review labels rather than a speculative public API. The future reducer must be +independently callable and consume normalized update evidence directly. It may +not require policy, deployment, health, correlation, or server reducer output. +The shared `sccm-keys-5.00.9128-experimental-v1` profile remains Low confidence +and cannot be promoted into an exact transaction or correlation-ready fact. + +## Synthetic-data boundary + +All evidence is generated for this repository. Allowed identity material is +limited to `LAB-CLIENT-01`, site code `LAB`, RFC-style test UUIDs, opaque +`CI-UPDATE-*`/`CONTENT-UPDATE-*`/`JOB-UPDATE-*` labels, `safe:` correlation +handles, and `SYNTHETIC://` provenance. No customer hostname, path, user, SID, +tenant, token, certificate, serial, deployment name, or copied production log +text is permitted. + +Complete `ccmLog` evidence uses the existing CCM grammar. The two +`rotation-boundary` fragments and the exact 128-byte `capped` prefix are +deliberately incomplete and cannot produce a key, phase, or terminal result. +The `supplemental-conflict` CBS file remains a separately typed supplemental +source; it is not converted into SCCM/CCM grammar. + +## Coverage and confidence boundary + +The matrix explicitly exercises `captured`, `absent`, `accessDenied`, `capped`, +`skipped`, `unsupported`, `parseFailed`, and incomplete physical-fragment +states. Every state other than complete captured evidence is coverage or +capability information, never proof of success/failure. + +Future counterpart-ready facts are emitted only when the synthetic +`updates-client-5.00.test-v1` profile directly supplies exact update/CI/content +and safe client/site/SUP handles. Their timestamp provenance must equal the +normalized cited CCM record; an unavailable SUP handle remains `null`. They +remain client facts for future #330/#333 work, with `correlationEligible: +false` while `topologyCompatibilityEvaluated: false`. Time alone is never +eligible, no counterpart fact may claim any topology compatibility value +before #333 evaluates it, and no server cause is claimed. + +All required exact transaction fields must co-occur in one cited complete CCM +record. High success/failure outcomes also require an exact-key, +phase-compatible source record with the claimed disposition/terminal marker. +An explicitly incompatible topology cannot remain correlation-eligible. + +Expected coverage and artifact provenance are exact, one-to-one projections of +the manifest. Absent/skipped sources do not claim physical-fragment +completeness, and profile families are validated only from compatible captured +evidence. Client role/catalog/group/basename/rotation/path identities are +coherent, physical paths/fingerprints cannot alias another artifact, and every +captured or capped artifact has a non-empty path fingerprint. + +## Replay + +From the repository root: + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_updates_fixture_contract +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +``` + +The focused contract validates the exact 17-scenario directory set, 51 +manifest artifacts, 43 physical files, capture-state rules, safe paths, +manifest byte counts, no orphans, physical evidence line references, CCM +framing, exact rollover paths, the capped payload, exact corpus hashes and +record totals, stable outcome labels, independent-reducer boundary, and +correlation-safe facts. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..873a3ed82 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/expected.json new file mode 100644 index 000000000..f7aa51875 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/expected.json @@ -0,0 +1,147 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "access-denied", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-updates", + "state": "accessDenied" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-access-denied-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-access-denied-02-install-denied", + "captureState": "accessDenied", + "encoding": null, + "byteLimit": null, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000013", + "key": { + "updateId": "32300000-0000-0000-0000-000000000013", + "ciId": "CI-UPDATE-13", + "contentId": "CONTENT-UPDATE-13", + "updateJobId": "JOB-UPDATE-13", + "clientHandle": "safe:client:updates-13", + "siteCode": "LAB", + "supHostHandle": null, + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "install", + "state": "incomplete", + "lastSuccessfulPhase": "scan", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "coverageGapArtifactIds": [ + "client-updates" + ], + "nextArtifact": { + "logicalArtifactId": "client-updates", + "reason": "Collect the smallest bounded client-updates continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-access-denied-01-scan", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:access-denied", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000013", + "class": "insufficientEvidence", + "phase": "install", + "lastSuccessfulPhase": "scan", + "confidence": "medium", + "confidenceCeiling": "medium", + "nextArtifact": { + "logicalArtifactId": "client-updates", + "reason": "Collect the smallest bounded client-updates continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-access-denied-01-scan", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": false, + "counterpartReadyFacts": [] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/manifest.json new file mode 100644 index 000000000..2fab1cdcd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/manifest.json @@ -0,0 +1,66 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-access-denied-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-access-denied-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T14:59:59Z", + "bytesCopied": 389, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-access-denied-02-install-denied", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "accessDenied", + "originalBasename": "UpdatesHandler.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesHandler.log", + "pathFingerprint": "synthetic:updates-access-denied-02-install-denied", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T14:59:59Z", + "bytesCopied": 0, + "relativePath": null + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..edab2fe2d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/evidence/client-content/current/DataTransferService.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..9c71e3311 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/expected.json new file mode 100644 index 000000000..c8d45e110 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/expected.json @@ -0,0 +1,209 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "capped", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-location-services-shared", + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-content", + "state": "capped" + }, + { + "logicalArtifactId": "client-location-services-shared", + "state": "captured" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-capped-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-capped-02-sup", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-capped-03-download", + "captureState": "capped", + "encoding": "utf-8", + "byteLimit": 128, + "limitApplied": true + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000012", + "key": { + "updateId": "32300000-0000-0000-0000-000000000012", + "ciId": "CI-UPDATE-12", + "contentId": "CONTENT-UPDATE-12", + "updateJobId": "JOB-UPDATE-12", + "clientHandle": "safe:client:updates-12", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "download", + "state": "incomplete", + "lastSuccessfulPhase": "locateSup", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "coverageGapArtifactIds": [ + "client-content" + ], + "nextArtifact": { + "logicalArtifactId": "client-content", + "reason": "Collect the smallest bounded client-content continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-capped-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-capped-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-capped-03-download", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:capped", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000012", + "class": "insufficientEvidence", + "phase": "download", + "lastSuccessfulPhase": "locateSup", + "confidence": "medium", + "confidenceCeiling": "medium", + "nextArtifact": { + "logicalArtifactId": "client-content", + "reason": "Collect the smallest bounded client-content continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-capped-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-capped-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-capped-03-download", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": true, + "counterpartReadyFacts": [ + { + "updateId": "32300000-0000-0000-0000-000000000012", + "ciId": "CI-UPDATE-12", + "contentId": "CONTENT-UPDATE-12", + "updateJobId": "JOB-UPDATE-12", + "clientHandle": "safe:client:updates-12", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "keyConfidence": "exact", + "correlationEligible": false, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "timestampProvenance": { + "normalizedUtc": "2026-07-30T13:00:02.000Z", + "utcMillis": 1785416402000, + "offsetMinutes": 0, + "orderingState": "normalizedUtc" + }, + "evidence": { + "artifactId": "updates-capped-02-sup", + "startLine": 1, + "endLine": 1 + } + } + ] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/manifest.json new file mode 100644 index 000000000..8d77843c1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/manifest.json @@ -0,0 +1,100 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-capped-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-capped-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T13:59:59Z", + "bytesCopied": 769, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-capped-02-sup", + "designOnlyCatalog": { + "entryId": "client-location-services-shared", + "groupMemberships": [ + "client-location-services-shared" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "LocationServices.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/LocationServices.log", + "pathFingerprint": "synthetic:updates-capped-02-sup", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T13:59:59Z", + "bytesCopied": 393, + "relativePath": "evidence/client-location-services-shared/current/LocationServices.log" + }, + { + "artifactId": "updates-capped-03-download", + "designOnlyCatalog": { + "entryId": "client-content", + "groupMemberships": [ + "client-content" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "capped", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 128, + "limitApplied": true + }, + "originalBasename": "DataTransferService.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DataTransferService.log", + "pathFingerprint": "synthetic:updates-capped-03-download", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T13:59:59Z", + "bytesCopied": 128, + "truncated": true, + "relativePath": "evidence/client-content/current/DataTransferService.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-content/current/ContentTransferManager.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-content/current/ContentTransferManager.log new file mode 100644 index 000000000..7df42e68a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-content/current/ContentTransferManager.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..b641f84f0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..1c43fad8a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/expected.json new file mode 100644 index 000000000..2748c471f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/expected.json @@ -0,0 +1,202 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "content-failure", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-content", + "client-location-services-shared", + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-content", + "state": "captured" + }, + { + "logicalArtifactId": "client-location-services-shared", + "state": "captured" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-content-failure-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-content-failure-02-sup", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-content-failure-03-download", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000005", + "key": { + "updateId": "32300000-0000-0000-0000-000000000005", + "ciId": "CI-UPDATE-05", + "contentId": "CONTENT-UPDATE-05", + "updateJobId": "JOB-UPDATE-05", + "clientHandle": "safe:client:updates-05", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "download", + "state": "failed", + "lastSuccessfulPhase": "locateSup", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-content-failure-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-content-failure-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-content-failure-03-download", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:content-failure", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000005", + "class": "confirmedFailure", + "phase": "download", + "lastSuccessfulPhase": "locateSup", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-content-failure-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-content-failure-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-content-failure-03-download", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": true, + "counterpartReadyFacts": [ + { + "updateId": "32300000-0000-0000-0000-000000000005", + "ciId": "CI-UPDATE-05", + "contentId": "CONTENT-UPDATE-05", + "updateJobId": "JOB-UPDATE-05", + "clientHandle": "safe:client:updates-05", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "keyConfidence": "exact", + "correlationEligible": false, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "timestampProvenance": { + "normalizedUtc": "2026-07-30T06:00:02.000Z", + "utcMillis": 1785391202000, + "offsetMinutes": 0, + "orderingState": "normalizedUtc" + }, + "evidence": { + "artifactId": "updates-content-failure-02-sup", + "startLine": 1, + "endLine": 1 + } + } + ] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/manifest.json new file mode 100644 index 000000000..aa8e5a1b7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/manifest.json @@ -0,0 +1,99 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-content-failure-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-content-failure-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T06:59:59Z", + "bytesCopied": 787, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-content-failure-02-sup", + "designOnlyCatalog": { + "entryId": "client-location-services-shared", + "groupMemberships": [ + "client-location-services-shared" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "LocationServices.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/LocationServices.log", + "pathFingerprint": "synthetic:updates-content-failure-02-sup", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T06:59:59Z", + "bytesCopied": 402, + "relativePath": "evidence/client-location-services-shared/current/LocationServices.log" + }, + { + "artifactId": "updates-content-failure-03-download", + "designOnlyCatalog": { + "entryId": "client-content", + "groupMemberships": [ + "client-content" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ContentTransferManager.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ContentTransferManager.log", + "pathFingerprint": "synthetic:updates-content-failure-03-download", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T06:59:59Z", + "bytesCopied": 431, + "relativePath": "evidence/client-content/current/ContentTransferManager.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..39c48e361 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/evidence/client-updates/current/WUAHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/evidence/client-updates/current/WUAHandler.log new file mode 100644 index 000000000..68985ead0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/evidence/client-updates/current/WUAHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/expected.json new file mode 100644 index 000000000..faca40305 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/expected.json @@ -0,0 +1,149 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "evaluation-failure", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-evaluation-failure-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-evaluation-failure-02-evaluation", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000004", + "key": { + "updateId": "32300000-0000-0000-0000-000000000004", + "ciId": "CI-UPDATE-04", + "contentId": "CONTENT-UPDATE-04", + "updateJobId": "JOB-UPDATE-04", + "clientHandle": "safe:client:updates-04", + "siteCode": "LAB", + "supHostHandle": null, + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "evaluate", + "state": "failed", + "lastSuccessfulPhase": "scan", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-evaluation-failure-01-scan", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-evaluation-failure-02-evaluation", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:evaluation-failure", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000004", + "class": "confirmedFailure", + "phase": "evaluate", + "lastSuccessfulPhase": "scan", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-evaluation-failure-01-scan", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-evaluation-failure-02-evaluation", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": false, + "counterpartReadyFacts": [] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/manifest.json new file mode 100644 index 000000000..2ff903685 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/manifest.json @@ -0,0 +1,71 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-evaluation-failure-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-evaluation-failure-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T05:59:59Z", + "bytesCopied": 394, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-evaluation-failure-02-evaluation", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "WUAHandler.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/WUAHandler.log", + "pathFingerprint": "synthetic:updates-evaluation-failure-02-evaluation", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T05:59:59Z", + "bytesCopied": 422, + "relativePath": "evidence/client-updates/current/WUAHandler.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/evidence/client-updates/current/UpdatesDeployment.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/evidence/client-updates/current/UpdatesDeployment.log new file mode 100644 index 000000000..b616e72e4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/evidence/client-updates/current/UpdatesDeployment.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/expected.json new file mode 100644 index 000000000..bb41c279d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/expected.json @@ -0,0 +1,173 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "incomplete", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-maintenance-window", + "state": "absent" + }, + { + "logicalArtifactId": "client-policy-state", + "state": "absent" + }, + { + "logicalArtifactId": "client-reboot", + "state": "absent" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-incomplete-01-deployment", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-incomplete-02-window-absent", + "captureState": "absent", + "encoding": null, + "byteLimit": null, + "limitApplied": false + }, + { + "artifactId": "updates-incomplete-03-reboot-absent", + "captureState": "absent", + "encoding": null, + "byteLimit": null, + "limitApplied": false + }, + { + "artifactId": "updates-incomplete-04-report-absent", + "captureState": "absent", + "encoding": null, + "byteLimit": null, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000010", + "key": { + "updateId": "32300000-0000-0000-0000-000000000010", + "ciId": "CI-UPDATE-10", + "contentId": "CONTENT-UPDATE-10", + "updateJobId": "JOB-UPDATE-10", + "clientHandle": "safe:client:updates-10", + "siteCode": "LAB", + "supHostHandle": null, + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "maintenanceWindow", + "state": "incomplete", + "lastSuccessfulPhase": "download", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "coverageGapArtifactIds": [ + "client-maintenance-window" + ], + "nextArtifact": { + "logicalArtifactId": "client-maintenance-window", + "reason": "Collect the smallest bounded client-maintenance-window continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-incomplete-01-deployment", + "startLine": 1, + "endLine": 4 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:incomplete", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000010", + "class": "insufficientEvidence", + "phase": "maintenanceWindow", + "lastSuccessfulPhase": "download", + "confidence": "medium", + "confidenceCeiling": "medium", + "nextArtifact": { + "logicalArtifactId": "client-maintenance-window", + "reason": "Collect the smallest bounded client-maintenance-window continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-incomplete-01-deployment", + "startLine": 1, + "endLine": 4 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": false, + "counterpartReadyFacts": [] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/manifest.json new file mode 100644 index 000000000..1dda221da --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/manifest.json @@ -0,0 +1,109 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-incomplete-01-deployment", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "UpdatesDeployment.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesDeployment.log", + "pathFingerprint": "synthetic:updates-incomplete-01-deployment", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T11:59:59Z", + "bytesCopied": 1589, + "relativePath": "evidence/client-updates/current/UpdatesDeployment.log" + }, + { + "artifactId": "updates-incomplete-02-window-absent", + "designOnlyCatalog": { + "entryId": "client-maintenance-window", + "groupMemberships": [ + "client-maintenance-window" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "absent", + "originalBasename": "ServiceWindowManager.log", + "sanitizedSourcePath": null, + "pathFingerprint": null, + "rotation": { + "kind": "current" + }, + "sourceVersion": null, + "capturedUtc": "2026-07-30T11:59:59Z", + "bytesCopied": 0, + "relativePath": null + }, + { + "artifactId": "updates-incomplete-03-reboot-absent", + "designOnlyCatalog": { + "entryId": "client-reboot", + "groupMemberships": [ + "client-reboot" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "absent", + "originalBasename": "RebootCoordinator.log", + "sanitizedSourcePath": null, + "pathFingerprint": null, + "rotation": { + "kind": "current" + }, + "sourceVersion": null, + "capturedUtc": "2026-07-30T11:59:59Z", + "bytesCopied": 0, + "relativePath": null + }, + { + "artifactId": "updates-incomplete-04-report-absent", + "designOnlyCatalog": { + "entryId": "client-policy-state", + "groupMemberships": [ + "client-policy-state" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "absent", + "originalBasename": "StateMessage.log", + "sanitizedSourcePath": null, + "pathFingerprint": null, + "rotation": { + "kind": "current" + }, + "sourceVersion": null, + "capturedUtc": "2026-07-30T11:59:59Z", + "bytesCopied": 0, + "relativePath": null + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..95d98008a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-content/current/DataTransferService.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..20507d372 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..34b409cb0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-updates/current/UpdatesHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-updates/current/UpdatesHandler.log new file mode 100644 index 000000000..15b4a6077 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-updates/current/UpdatesHandler.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/expected.json new file mode 100644 index 000000000..aa5aff517 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/expected.json @@ -0,0 +1,219 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "install-failure", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-content", + "client-location-services-shared", + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-content", + "state": "captured" + }, + { + "logicalArtifactId": "client-location-services-shared", + "state": "captured" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-install-failure-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-install-failure-02-sup", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-install-failure-03-download", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-install-failure-04-install", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000008", + "key": { + "updateId": "32300000-0000-0000-0000-000000000008", + "ciId": "CI-UPDATE-08", + "contentId": "CONTENT-UPDATE-08", + "updateJobId": "JOB-UPDATE-08", + "clientHandle": "safe:client:updates-08", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "install", + "state": "failed", + "lastSuccessfulPhase": "maintenanceWindow", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-install-failure-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-install-failure-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-install-failure-03-download", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-install-failure-04-install", + "startLine": 1, + "endLine": 2 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:install-failure", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000008", + "class": "confirmedFailure", + "phase": "install", + "lastSuccessfulPhase": "maintenanceWindow", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-install-failure-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-install-failure-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-install-failure-03-download", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-install-failure-04-install", + "startLine": 1, + "endLine": 2 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": true, + "counterpartReadyFacts": [ + { + "updateId": "32300000-0000-0000-0000-000000000008", + "ciId": "CI-UPDATE-08", + "contentId": "CONTENT-UPDATE-08", + "updateJobId": "JOB-UPDATE-08", + "clientHandle": "safe:client:updates-08", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "keyConfidence": "exact", + "correlationEligible": false, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "timestampProvenance": { + "normalizedUtc": "2026-07-30T09:00:02.000Z", + "utcMillis": 1785402002000, + "offsetMinutes": 0, + "orderingState": "normalizedUtc" + }, + "evidence": { + "artifactId": "updates-install-failure-02-sup", + "startLine": 1, + "endLine": 1 + } + } + ] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/manifest.json new file mode 100644 index 000000000..0633bb257 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/manifest.json @@ -0,0 +1,127 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-install-failure-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-install-failure-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T09:59:59Z", + "bytesCopied": 787, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-install-failure-02-sup", + "designOnlyCatalog": { + "entryId": "client-location-services-shared", + "groupMemberships": [ + "client-location-services-shared" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "LocationServices.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/LocationServices.log", + "pathFingerprint": "synthetic:updates-install-failure-02-sup", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T09:59:59Z", + "bytesCopied": 402, + "relativePath": "evidence/client-location-services-shared/current/LocationServices.log" + }, + { + "artifactId": "updates-install-failure-03-download", + "designOnlyCatalog": { + "entryId": "client-content", + "groupMemberships": [ + "client-content" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "DataTransferService.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DataTransferService.log", + "pathFingerprint": "synthetic:updates-install-failure-03-download", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T09:59:59Z", + "bytesCopied": 405, + "relativePath": "evidence/client-content/current/DataTransferService.log" + }, + { + "artifactId": "updates-install-failure-04-install", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "UpdatesHandler.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesHandler.log", + "pathFingerprint": "synthetic:updates-install-failure-04-install", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T09:59:59Z", + "bytesCopied": 826, + "relativePath": "evidence/client-updates/current/UpdatesHandler.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..b27e0af02 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/evidence/client-updates/current/UpdatesStore.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/evidence/client-updates/current/UpdatesStore.log new file mode 100644 index 000000000..3b30e817a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/evidence/client-updates/current/UpdatesStore.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/expected.json new file mode 100644 index 000000000..8465974a5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/expected.json @@ -0,0 +1,162 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "invalid-offset", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-invalid-offset-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-invalid-offset-02-evaluation", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000014", + "key": { + "updateId": "32300000-0000-0000-0000-000000000014", + "ciId": "CI-UPDATE-14", + "contentId": "CONTENT-UPDATE-14", + "updateJobId": "JOB-UPDATE-14", + "clientHandle": "safe:client:updates-14", + "siteCode": "LAB", + "supHostHandle": null, + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "evaluate", + "state": "contradictory", + "lastSuccessfulPhase": "scan", + "classification": "insufficientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "coverageGapArtifactIds": [ + "client-updates" + ], + "nextArtifact": { + "logicalArtifactId": "client-updates", + "reason": "Collect the smallest bounded client-updates continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-invalid-offset-01-scan", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-invalid-offset-02-evaluation", + "startLine": 1, + "endLine": 1 + } + ], + "ordering": { + "crossArtifactComparable": false, + "reason": "invalidOffset", + "highConfidenceEligible": false + } + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:invalid-offset", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000014", + "class": "insufficientEvidence", + "phase": "evaluate", + "lastSuccessfulPhase": "scan", + "confidence": "low", + "confidenceCeiling": "low", + "nextArtifact": { + "logicalArtifactId": "client-updates", + "reason": "Collect the smallest bounded client-updates continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-invalid-offset-01-scan", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-invalid-offset-02-evaluation", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": false, + "counterpartReadyFacts": [] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/manifest.json new file mode 100644 index 000000000..8e3e2479a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/manifest.json @@ -0,0 +1,71 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-invalid-offset-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-invalid-offset-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T16:59:59Z", + "bytesCopied": 390, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-invalid-offset-02-evaluation", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "UpdatesStore.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesStore.log", + "pathFingerprint": "synthetic:updates-invalid-offset-02-evaluation", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T16:59:59Z", + "bytesCopied": 420, + "relativePath": "evidence/client-updates/current/UpdatesStore.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..9d9cb6f28 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-content/current/DataTransferService.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..91fe7cca0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-maintenance-window/current/ServiceWindowManager.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-maintenance-window/current/ServiceWindowManager.log new file mode 100644 index 000000000..0c250c9bd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-maintenance-window/current/ServiceWindowManager.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..2262d0ecc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/expected.json new file mode 100644 index 000000000..e381274c0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/expected.json @@ -0,0 +1,232 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "maintenance-window", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-content", + "client-location-services-shared", + "client-maintenance-window", + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-content", + "state": "captured" + }, + { + "logicalArtifactId": "client-location-services-shared", + "state": "captured" + }, + { + "logicalArtifactId": "client-maintenance-window", + "state": "captured" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-maintenance-window-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-maintenance-window-02-sup", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-maintenance-window-03-download", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-maintenance-window-04-window", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000006", + "key": { + "updateId": "32300000-0000-0000-0000-000000000006", + "ciId": "CI-UPDATE-06", + "contentId": "CONTENT-UPDATE-06", + "updateJobId": "JOB-UPDATE-06", + "clientHandle": "safe:client:updates-06", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "maintenanceWindow", + "state": "blockedOrDeferred", + "lastSuccessfulPhase": "download", + "classification": "blockedOrDeferred", + "confidence": "medium", + "confidenceCeiling": "medium", + "coverageGapArtifactIds": [ + "client-maintenance-window" + ], + "nextArtifact": { + "logicalArtifactId": "client-maintenance-window", + "reason": "Collect the smallest bounded client-maintenance-window continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-maintenance-window-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-maintenance-window-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-maintenance-window-03-download", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-maintenance-window-04-window", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:maintenance-window", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000006", + "class": "blockedOrDeferred", + "phase": "maintenanceWindow", + "lastSuccessfulPhase": "download", + "confidence": "medium", + "confidenceCeiling": "medium", + "nextArtifact": { + "logicalArtifactId": "client-maintenance-window", + "reason": "Collect the smallest bounded client-maintenance-window continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-maintenance-window-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-maintenance-window-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-maintenance-window-03-download", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-maintenance-window-04-window", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": true, + "counterpartReadyFacts": [ + { + "updateId": "32300000-0000-0000-0000-000000000006", + "ciId": "CI-UPDATE-06", + "contentId": "CONTENT-UPDATE-06", + "updateJobId": "JOB-UPDATE-06", + "clientHandle": "safe:client:updates-06", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "keyConfidence": "exact", + "correlationEligible": false, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "timestampProvenance": { + "normalizedUtc": "2026-07-30T07:00:02.000Z", + "utcMillis": 1785394802000, + "offsetMinutes": 0, + "orderingState": "normalizedUtc" + }, + "evidence": { + "artifactId": "updates-maintenance-window-02-sup", + "startLine": 1, + "endLine": 1 + } + } + ] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/manifest.json new file mode 100644 index 000000000..394391b24 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/manifest.json @@ -0,0 +1,127 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-maintenance-window-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-maintenance-window-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T07:59:59Z", + "bytesCopied": 793, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-maintenance-window-02-sup", + "designOnlyCatalog": { + "entryId": "client-location-services-shared", + "groupMemberships": [ + "client-location-services-shared" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "LocationServices.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/LocationServices.log", + "pathFingerprint": "synthetic:updates-maintenance-window-02-sup", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T07:59:59Z", + "bytesCopied": 405, + "relativePath": "evidence/client-location-services-shared/current/LocationServices.log" + }, + { + "artifactId": "updates-maintenance-window-03-download", + "designOnlyCatalog": { + "entryId": "client-content", + "groupMemberships": [ + "client-content" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "DataTransferService.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DataTransferService.log", + "pathFingerprint": "synthetic:updates-maintenance-window-03-download", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T07:59:59Z", + "bytesCopied": 408, + "relativePath": "evidence/client-content/current/DataTransferService.log" + }, + { + "artifactId": "updates-maintenance-window-04-window", + "designOnlyCatalog": { + "entryId": "client-maintenance-window", + "groupMemberships": [ + "client-maintenance-window" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ServiceWindowManager.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ServiceWindowManager.log", + "pathFingerprint": "synthetic:updates-maintenance-window-04-window", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T07:59:59Z", + "bytesCopied": 442, + "relativePath": "evidence/client-maintenance-window/current/ServiceWindowManager.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..bb1d8aa10 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/expected.json new file mode 100644 index 000000000..f5ee6b695 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/expected.json @@ -0,0 +1,145 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "malformed", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "unvalidatedVersion", + "profileId": null, + "sourceVersionPrefix": null, + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-updates", + "state": "parseFailed" + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "state": "unsupported" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-malformed-01-malformed", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-malformed-02-store-parse-failed", + "captureState": "parseFailed", + "encoding": null, + "byteLimit": null, + "limitApplied": false + }, + { + "artifactId": "updates-malformed-03-supplemental-unsupported", + "captureState": "unsupported", + "encoding": null, + "byteLimit": null, + "limitApplied": false + } + ], + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "updates:source-local:malformed", + "key": null, + "keyConfidence": "none", + "phase": null, + "state": "malformed", + "classification": "lowConfidenceSymptom", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "lastSuccessfulPhase": null, + "nextArtifact": { + "logicalArtifactId": "client-updates", + "reason": "Collect the smallest bounded client-updates continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-malformed-01-malformed", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "findings": [ + { + "findingId": "finding:updates:malformed", + "subjectId": "updates:source-local:malformed", + "class": "lowConfidenceSymptom", + "phase": null, + "lastSuccessfulPhase": null, + "confidence": "low", + "confidenceCeiling": "low", + "nextArtifact": { + "logicalArtifactId": "client-updates", + "reason": "Collect the smallest bounded client-updates continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-malformed-01-malformed", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": false, + "counterpartReadyFacts": [] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/manifest.json new file mode 100644 index 000000000..e37152270 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/manifest.json @@ -0,0 +1,89 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-malformed-01-malformed", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-malformed-01-malformed", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.UNKNOWN.0000", + "capturedUtc": "2026-07-30T15:59:59Z", + "bytesCopied": 233, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-malformed-02-store-parse-failed", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "parseFailed", + "originalBasename": "UpdatesStore.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesStore.log", + "pathFingerprint": "synthetic:updates-malformed-02-store-parse-failed", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "sourceVersion": "5.00.UNKNOWN.0000", + "capturedUtc": "2026-07-30T15:59:59Z", + "bytesCopied": 0, + "relativePath": null + }, + { + "artifactId": "updates-malformed-03-supplemental-unsupported", + "designOnlyCatalog": { + "entryId": "client-windows-update-supplemental", + "groupMemberships": [ + "client-windows-update-supplemental" + ] + }, + "role": "client", + "kind": "cbsLog", + "captureState": "unsupported", + "originalBasename": "CBS.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/Windows/Logs/CBS/CBS.log", + "pathFingerprint": "synthetic:updates-malformed-03-supplemental-unsupported", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "sourceVersion": null, + "capturedUtc": "2026-07-30T15:59:59Z", + "bytesCopied": 0, + "relativePath": null + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..66b0627f2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/expected.json new file mode 100644 index 000000000..0069bf224 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/expected.json @@ -0,0 +1,151 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "no-sup", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-location-services-shared", + "state": "absent" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-no-sup-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-no-sup-02-sup-absent", + "captureState": "absent", + "encoding": null, + "byteLimit": null, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000002", + "key": { + "updateId": "32300000-0000-0000-0000-000000000002", + "ciId": "CI-UPDATE-02", + "contentId": "CONTENT-UPDATE-02", + "updateJobId": "JOB-UPDATE-02", + "clientHandle": "safe:client:updates-02", + "siteCode": "LAB", + "supHostHandle": null, + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "locateSup", + "state": "incomplete", + "lastSuccessfulPhase": "evaluate", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "coverageGapArtifactIds": [ + "client-location-services-shared" + ], + "nextArtifact": { + "logicalArtifactId": "client-location-services-shared", + "reason": "Collect the smallest bounded client-location-services-shared continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-no-sup-01-scan", + "startLine": 1, + "endLine": 2 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:no-sup", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000002", + "class": "insufficientEvidence", + "phase": "locateSup", + "lastSuccessfulPhase": "evaluate", + "confidence": "medium", + "confidenceCeiling": "medium", + "nextArtifact": { + "logicalArtifactId": "client-location-services-shared", + "reason": "Collect the smallest bounded client-location-services-shared continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-no-sup-01-scan", + "startLine": 1, + "endLine": 2 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": false, + "counterpartReadyFacts": [] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/manifest.json new file mode 100644 index 000000000..47a36cadc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/manifest.json @@ -0,0 +1,65 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-no-sup-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-no-sup-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T03:59:59Z", + "bytesCopied": 769, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-no-sup-02-sup-absent", + "designOnlyCatalog": { + "entryId": "client-location-services-shared", + "groupMemberships": [ + "client-location-services-shared" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "absent", + "originalBasename": "LocationServices.log", + "sanitizedSourcePath": null, + "pathFingerprint": null, + "rotation": { + "kind": "current" + }, + "sourceVersion": null, + "capturedUtc": "2026-07-30T03:59:59Z", + "bytesCopied": 0, + "relativePath": null + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..c57d9c957 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-reboot/current/RebootCoordinator.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-reboot/current/RebootCoordinator.log new file mode 100644 index 000000000..71ce0d65a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-reboot/current/RebootCoordinator.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..99a1cb1da --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-updates/current/UpdatesDeployment.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-updates/current/UpdatesDeployment.log new file mode 100644 index 000000000..235f94c60 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-updates/current/UpdatesDeployment.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/expected.json new file mode 100644 index 000000000..056b937be --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/expected.json @@ -0,0 +1,227 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "reboot-pending", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-location-services-shared", + "client-reboot", + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-location-services-shared", + "state": "captured" + }, + { + "logicalArtifactId": "client-reboot", + "state": "captured" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-reboot-pending-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-reboot-pending-02-sup", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-reboot-pending-03-deployment", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-reboot-pending-04-reboot", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000007", + "key": { + "updateId": "32300000-0000-0000-0000-000000000007", + "ciId": "CI-UPDATE-07", + "contentId": "CONTENT-UPDATE-07", + "updateJobId": "JOB-UPDATE-07", + "clientHandle": "safe:client:updates-07", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "reboot", + "state": "blockedOrDeferred", + "lastSuccessfulPhase": "install", + "classification": "blockedOrDeferred", + "confidence": "medium", + "confidenceCeiling": "medium", + "coverageGapArtifactIds": [ + "client-reboot" + ], + "nextArtifact": { + "logicalArtifactId": "client-reboot", + "reason": "Collect the smallest bounded client-reboot continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-reboot-pending-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-reboot-pending-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-reboot-pending-03-deployment", + "startLine": 1, + "endLine": 3 + }, + { + "artifactId": "updates-reboot-pending-04-reboot", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:reboot-pending", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000007", + "class": "blockedOrDeferred", + "phase": "reboot", + "lastSuccessfulPhase": "install", + "confidence": "medium", + "confidenceCeiling": "medium", + "nextArtifact": { + "logicalArtifactId": "client-reboot", + "reason": "Collect the smallest bounded client-reboot continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-reboot-pending-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-reboot-pending-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-reboot-pending-03-deployment", + "startLine": 1, + "endLine": 3 + }, + { + "artifactId": "updates-reboot-pending-04-reboot", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": true, + "counterpartReadyFacts": [ + { + "updateId": "32300000-0000-0000-0000-000000000007", + "ciId": "CI-UPDATE-07", + "contentId": "CONTENT-UPDATE-07", + "updateJobId": "JOB-UPDATE-07", + "clientHandle": "safe:client:updates-07", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "keyConfidence": "exact", + "correlationEligible": false, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "timestampProvenance": { + "normalizedUtc": "2026-07-30T08:00:02.000Z", + "utcMillis": 1785398402000, + "offsetMinutes": 0, + "orderingState": "normalizedUtc" + }, + "evidence": { + "artifactId": "updates-reboot-pending-02-sup", + "startLine": 1, + "endLine": 1 + } + } + ] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/manifest.json new file mode 100644 index 000000000..9d8b8cc62 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/manifest.json @@ -0,0 +1,127 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-reboot-pending-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-reboot-pending-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T08:59:59Z", + "bytesCopied": 785, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-reboot-pending-02-sup", + "designOnlyCatalog": { + "entryId": "client-location-services-shared", + "groupMemberships": [ + "client-location-services-shared" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "LocationServices.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/LocationServices.log", + "pathFingerprint": "synthetic:updates-reboot-pending-02-sup", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T08:59:59Z", + "bytesCopied": 401, + "relativePath": "evidence/client-location-services-shared/current/LocationServices.log" + }, + { + "artifactId": "updates-reboot-pending-03-deployment", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "UpdatesDeployment.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesDeployment.log", + "pathFingerprint": "synthetic:updates-reboot-pending-03-deployment", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T08:59:59Z", + "bytesCopied": 1209, + "relativePath": "evidence/client-updates/current/UpdatesDeployment.log" + }, + { + "artifactId": "updates-reboot-pending-04-reboot", + "designOnlyCatalog": { + "entryId": "client-reboot", + "groupMemberships": [ + "client-reboot" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "RebootCoordinator.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/RebootCoordinator.log", + "pathFingerprint": "synthetic:updates-reboot-pending-04-reboot", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T08:59:59Z", + "bytesCopied": 398, + "relativePath": "evidence/client-reboot/current/RebootCoordinator.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..22b5fa556 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-policy-state/current/StateMessage.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-policy-state/current/StateMessage.log new file mode 100644 index 000000000..f7d7b4c44 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-policy-state/current/StateMessage.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..5c2d6d3e5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-updates/current/UpdatesDeployment.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-updates/current/UpdatesDeployment.log new file mode 100644 index 000000000..2c615c25f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-updates/current/UpdatesDeployment.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/expected.json new file mode 100644 index 000000000..0046dc731 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/expected.json @@ -0,0 +1,219 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "reporting-failure", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-location-services-shared", + "client-policy-state", + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-location-services-shared", + "state": "captured" + }, + { + "logicalArtifactId": "client-policy-state", + "state": "captured" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-reporting-failure-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-reporting-failure-02-sup", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-reporting-failure-03-deployment", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-reporting-failure-04-report", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000009", + "key": { + "updateId": "32300000-0000-0000-0000-000000000009", + "ciId": "CI-UPDATE-09", + "contentId": "CONTENT-UPDATE-09", + "updateJobId": "JOB-UPDATE-09", + "clientHandle": "safe:client:updates-09", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "report", + "state": "failed", + "lastSuccessfulPhase": "reboot", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-reporting-failure-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-reporting-failure-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-reporting-failure-03-deployment", + "startLine": 1, + "endLine": 4 + }, + { + "artifactId": "updates-reporting-failure-04-report", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:reporting-failure", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000009", + "class": "confirmedFailure", + "phase": "report", + "lastSuccessfulPhase": "reboot", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-reporting-failure-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-reporting-failure-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-reporting-failure-03-deployment", + "startLine": 1, + "endLine": 4 + }, + { + "artifactId": "updates-reporting-failure-04-report", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": true, + "counterpartReadyFacts": [ + { + "updateId": "32300000-0000-0000-0000-000000000009", + "ciId": "CI-UPDATE-09", + "contentId": "CONTENT-UPDATE-09", + "updateJobId": "JOB-UPDATE-09", + "clientHandle": "safe:client:updates-09", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "keyConfidence": "exact", + "correlationEligible": false, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "timestampProvenance": { + "normalizedUtc": "2026-07-30T10:00:02.000Z", + "utcMillis": 1785405602000, + "offsetMinutes": 0, + "orderingState": "normalizedUtc" + }, + "evidence": { + "artifactId": "updates-reporting-failure-02-sup", + "startLine": 1, + "endLine": 1 + } + } + ] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/manifest.json new file mode 100644 index 000000000..0f5c4595d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/manifest.json @@ -0,0 +1,127 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-reporting-failure-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-reporting-failure-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T10:59:59Z", + "bytesCopied": 791, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-reporting-failure-02-sup", + "designOnlyCatalog": { + "entryId": "client-location-services-shared", + "groupMemberships": [ + "client-location-services-shared" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "LocationServices.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/LocationServices.log", + "pathFingerprint": "synthetic:updates-reporting-failure-02-sup", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T10:59:59Z", + "bytesCopied": 404, + "relativePath": "evidence/client-location-services-shared/current/LocationServices.log" + }, + { + "artifactId": "updates-reporting-failure-03-deployment", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "UpdatesDeployment.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesDeployment.log", + "pathFingerprint": "synthetic:updates-reporting-failure-03-deployment", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T10:59:59Z", + "bytesCopied": 1620, + "relativePath": "evidence/client-updates/current/UpdatesDeployment.log" + }, + { + "artifactId": "updates-reporting-failure-04-report", + "designOnlyCatalog": { + "entryId": "client-policy-state", + "groupMemberships": [ + "client-policy-state" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "StateMessage.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/StateMessage.log", + "pathFingerprint": "synthetic:updates-reporting-failure-04-report", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T10:59:59Z", + "bytesCopied": 421, + "relativePath": "evidence/client-policy-state/current/StateMessage.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..2ed36a116 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/same-minute-separate/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/same-minute-separate/expected.json new file mode 100644 index 000000000..021a05428 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/same-minute-separate/expected.json @@ -0,0 +1,161 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "same-minute-separate", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-same-minute-separate-01-updates", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000015", + "key": { + "updateId": "32300000-0000-0000-0000-000000000015", + "ciId": "CI-UPDATE-15", + "contentId": "CONTENT-UPDATE-15", + "updateJobId": "JOB-UPDATE-15", + "clientHandle": "safe:client:updates-15", + "siteCode": "LAB", + "supHostHandle": null, + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "report", + "state": "succeeded", + "lastSuccessfulPhase": "report", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-same-minute-separate-01-updates", + "startLine": 1, + "endLine": 1 + } + ] + }, + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000016", + "key": { + "updateId": "32300000-0000-0000-0000-000000000016", + "ciId": "CI-UPDATE-16", + "contentId": "CONTENT-UPDATE-16", + "updateJobId": "JOB-UPDATE-16", + "clientHandle": "safe:client:updates-16", + "siteCode": "LAB", + "supHostHandle": null, + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "install", + "state": "failed", + "lastSuccessfulPhase": null, + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-same-minute-separate-01-updates", + "startLine": 2, + "endLine": 2 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:same-minute-separate-second", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000016", + "class": "confirmedFailure", + "phase": "install", + "lastSuccessfulPhase": null, + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-same-minute-separate-01-updates", + "startLine": 2, + "endLine": 2 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": false, + "counterpartReadyFacts": [] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/same-minute-separate/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/same-minute-separate/manifest.json new file mode 100644 index 000000000..5491c1ec3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/same-minute-separate/manifest.json @@ -0,0 +1,43 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-same-minute-separate-01-updates", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "UpdatesHandler.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesHandler.log", + "pathFingerprint": "synthetic:updates-same-minute-separate-01-updates", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T17:59:59Z", + "bytesCopied": 830, + "relativePath": "evidence/client-updates/current/UpdatesHandler.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..73d025efe --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/expected.json new file mode 100644 index 000000000..e0c2645b5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/expected.json @@ -0,0 +1,132 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "scan-failure", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-updates", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-scan-failure-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000003", + "key": { + "updateId": "32300000-0000-0000-0000-000000000003", + "ciId": "CI-UPDATE-03", + "contentId": "CONTENT-UPDATE-03", + "updateJobId": "JOB-UPDATE-03", + "clientHandle": "safe:client:updates-03", + "siteCode": "LAB", + "supHostHandle": null, + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "scan", + "state": "failed", + "lastSuccessfulPhase": null, + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-scan-failure-01-scan", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [ + { + "findingId": "finding:updates:scan-failure", + "subjectId": "updates:update:32300000-0000-0000-0000-000000000003", + "class": "confirmedFailure", + "phase": "scan", + "lastSuccessfulPhase": null, + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-scan-failure-01-scan", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": false, + "counterpartReadyFacts": [] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/manifest.json new file mode 100644 index 000000000..b7bb6d585 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/manifest.json @@ -0,0 +1,43 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-scan-failure-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-scan-failure-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T04:59:59Z", + "bytesCopied": 411, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-content/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-content/current/DataTransferService.log new file mode 100644 index 000000000..a33488018 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-content/current/DataTransferService.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-location-services-shared/current/LocationServices.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-location-services-shared/current/LocationServices.log new file mode 100644 index 000000000..2e5bffe3c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-maintenance-window/current/ServiceWindowManager.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-maintenance-window/current/ServiceWindowManager.log new file mode 100644 index 000000000..5a8dfd895 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-maintenance-window/current/ServiceWindowManager.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-policy-state/current/StateMessage.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-policy-state/current/StateMessage.log new file mode 100644 index 000000000..c954da66a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-policy-state/current/StateMessage.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-reboot/current/RebootCoordinator.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-reboot/current/RebootCoordinator.log new file mode 100644 index 000000000..383dde391 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-reboot/current/RebootCoordinator.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-updates/current/ScanAgent.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-updates/current/ScanAgent.log new file mode 100644 index 000000000..bfcd6e052 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-updates/current/ScanAgent.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-updates/current/UpdatesHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-updates/current/UpdatesHandler.log new file mode 100644 index 000000000..27a252686 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-updates/current/UpdatesHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/expected.json new file mode 100644 index 000000000..ce67a355d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/expected.json @@ -0,0 +1,248 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "success", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-content", + "client-location-services-shared", + "client-maintenance-window", + "client-policy-state", + "client-reboot", + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-content", + "state": "captured" + }, + { + "logicalArtifactId": "client-location-services-shared", + "state": "captured" + }, + { + "logicalArtifactId": "client-maintenance-window", + "state": "captured" + }, + { + "logicalArtifactId": "client-policy-state", + "state": "captured" + }, + { + "logicalArtifactId": "client-reboot", + "state": "captured" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "state": "skipped" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-success-01-scan", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-success-02-sup", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-success-03-download", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-success-04-window", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-success-05-install", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-success-06-reboot", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-success-07-report", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-success-08-supplemental-skipped", + "captureState": "skipped", + "encoding": null, + "byteLimit": null, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000001", + "key": { + "updateId": "32300000-0000-0000-0000-000000000001", + "ciId": "CI-UPDATE-01", + "contentId": "CONTENT-UPDATE-01", + "updateJobId": "JOB-UPDATE-01", + "clientHandle": "safe:client:updates-01", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "report", + "state": "succeeded", + "lastSuccessfulPhase": "report", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-success-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-success-02-sup", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-success-03-download", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-success-04-window", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-success-05-install", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-success-06-reboot", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "updates-success-07-report", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [], + "findings": [], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": true, + "counterpartReadyFacts": [ + { + "updateId": "32300000-0000-0000-0000-000000000001", + "ciId": "CI-UPDATE-01", + "contentId": "CONTENT-UPDATE-01", + "updateJobId": "JOB-UPDATE-01", + "clientHandle": "safe:client:updates-01", + "siteCode": "LAB", + "supHostHandle": "safe:sup:lab-sup-01", + "keyConfidence": "exact", + "correlationEligible": false, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "timestampProvenance": { + "normalizedUtc": "2026-07-30T02:00:02.000Z", + "utcMillis": 1785376802000, + "offsetMinutes": 0, + "orderingState": "normalizedUtc" + }, + "evidence": { + "artifactId": "updates-success-02-sup", + "startLine": 1, + "endLine": 1 + } + } + ] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/manifest.json new file mode 100644 index 000000000..95a862097 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/manifest.json @@ -0,0 +1,233 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-success-01-scan", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ScanAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ScanAgent.log", + "pathFingerprint": "synthetic:updates-success-01-scan", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T02:59:59Z", + "bytesCopied": 771, + "relativePath": "evidence/client-updates/current/ScanAgent.log" + }, + { + "artifactId": "updates-success-02-sup", + "designOnlyCatalog": { + "entryId": "client-location-services-shared", + "groupMemberships": [ + "client-location-services-shared" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "LocationServices.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/LocationServices.log", + "pathFingerprint": "synthetic:updates-success-02-sup", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T02:59:59Z", + "bytesCopied": 394, + "relativePath": "evidence/client-location-services-shared/current/LocationServices.log" + }, + { + "artifactId": "updates-success-03-download", + "designOnlyCatalog": { + "entryId": "client-content", + "groupMemberships": [ + "client-content" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "DataTransferService.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DataTransferService.log", + "pathFingerprint": "synthetic:updates-success-03-download", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T02:59:59Z", + "bytesCopied": 397, + "relativePath": "evidence/client-content/current/DataTransferService.log" + }, + { + "artifactId": "updates-success-04-window", + "designOnlyCatalog": { + "entryId": "client-maintenance-window", + "groupMemberships": [ + "client-maintenance-window" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "ServiceWindowManager.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/ServiceWindowManager.log", + "pathFingerprint": "synthetic:updates-success-04-window", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T02:59:59Z", + "bytesCopied": 402, + "relativePath": "evidence/client-maintenance-window/current/ServiceWindowManager.log" + }, + { + "artifactId": "updates-success-05-install", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "UpdatesHandler.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesHandler.log", + "pathFingerprint": "synthetic:updates-success-05-install", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T02:59:59Z", + "bytesCopied": 391, + "relativePath": "evidence/client-updates/current/UpdatesHandler.log" + }, + { + "artifactId": "updates-success-06-reboot", + "designOnlyCatalog": { + "entryId": "client-reboot", + "groupMemberships": [ + "client-reboot" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "RebootCoordinator.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/RebootCoordinator.log", + "pathFingerprint": "synthetic:updates-success-06-reboot", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T02:59:59Z", + "bytesCopied": 392, + "relativePath": "evidence/client-reboot/current/RebootCoordinator.log" + }, + { + "artifactId": "updates-success-07-report", + "designOnlyCatalog": { + "entryId": "client-policy-state", + "groupMemberships": [ + "client-policy-state" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "StateMessage.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/StateMessage.log", + "pathFingerprint": "synthetic:updates-success-07-report", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T02:59:59Z", + "bytesCopied": 388, + "relativePath": "evidence/client-policy-state/current/StateMessage.log" + }, + { + "artifactId": "updates-success-08-supplemental-skipped", + "designOnlyCatalog": { + "entryId": "client-windows-update-supplemental", + "groupMemberships": [ + "client-windows-update-supplemental" + ] + }, + "role": "client", + "kind": "supplementalLog", + "captureState": "skipped", + "originalBasename": "ReportingEvents.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/WindowsUpdate/ReportingEvents.log", + "pathFingerprint": "synthetic:updates-success-08-supplemental-skipped", + "rotation": { + "kind": "current" + }, + "sourceVersion": null, + "capturedUtc": "2026-07-30T02:59:59Z", + "bytesCopied": 0, + "relativePath": null + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/evidence/client-updates/current/UpdatesHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/evidence/client-updates/current/UpdatesHandler.log new file mode 100644 index 000000000..1e9f2a07c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/evidence/client-updates/current/UpdatesHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/evidence/client-windows-update-supplemental/current/CBS.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/evidence/client-windows-update-supplemental/current/CBS.log new file mode 100644 index 000000000..458f83051 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/evidence/client-windows-update-supplemental/current/CBS.log @@ -0,0 +1 @@ +2026-07-30 18:00:00, Error CBS SYNTHETIC FIXTURE supplemental error-looking 0x80000017 without UpdateId KB or CI key diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/expected.json new file mode 100644 index 000000000..c49b469f6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/expected.json @@ -0,0 +1,170 @@ +{ + "contractState": "proposedPending318", + "workflow": "updates", + "scenario": "supplemental-conflict", + "stateChain": [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report" + ], + "analysisContract": { + "independentReducer": true, + "consumesOtherReducerOutput": false, + "policyOutputRequired": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selected", + "profileId": "updates-client-5.00.test-v1", + "sourceVersionPrefix": "5.00.TEST.", + "keyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "validatedArtifactFamilies": [ + "client-updates" + ] + }, + "reorderedInputDeterministic": true, + "coverage": [ + { + "logicalArtifactId": "client-updates", + "state": "captured" + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "state": "captured" + } + ], + "artifactProvenance": [ + { + "artifactId": "updates-supplemental-conflict-01-install", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "updates-supplemental-conflict-02-cbs", + "captureState": "captured", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "transactions": [ + { + "transactionId": "updates:update:32300000-0000-0000-0000-000000000017", + "key": { + "updateId": "32300000-0000-0000-0000-000000000017", + "ciId": "CI-UPDATE-17", + "contentId": "CONTENT-UPDATE-17", + "updateJobId": "JOB-UPDATE-17", + "clientHandle": "safe:client:updates-17", + "siteCode": "LAB", + "supHostHandle": null, + "confidence": "exact", + "extractionProfileId": "updates-client-5.00.test-v1" + }, + "phase": "install", + "state": "succeeded", + "lastSuccessfulPhase": "install", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + { + "artifactId": "updates-supplemental-conflict-01-install", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "sourceLocalObservations": [ + { + "observationId": "updates:source-local:supplemental-conflict", + "key": null, + "keyConfidence": "none", + "phase": "install", + "state": "contradictory", + "classification": "lowConfidenceSymptom", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "lastSuccessfulPhase": null, + "nextArtifact": { + "logicalArtifactId": "client-windows-update-supplemental", + "reason": "Collect the smallest bounded client-windows-update-supplemental continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-supplemental-conflict-02-cbs", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "findings": [ + { + "findingId": "finding:updates:supplemental-conflict", + "subjectId": "updates:source-local:supplemental-conflict", + "class": "lowConfidenceSymptom", + "phase": "install", + "lastSuccessfulPhase": null, + "confidence": "low", + "confidenceCeiling": "low", + "nextArtifact": { + "logicalArtifactId": "client-windows-update-supplemental", + "reason": "Collect the smallest bounded client-windows-update-supplemental continuation for this exact update subject." + }, + "evidence": [ + { + "artifactId": "updates-supplemental-conflict-02-cbs", + "startLine": 1, + "endLine": 1 + } + ] + } + ], + "correlationHandoff": { + "issue": "#333", + "serverPrerequisiteIssue": "#330", + "performed": false, + "timeOnlyEligible": false, + "topologyCompatibilityEvaluated": false, + "serverCauseClaimed": false, + "nativeAcceptanceClaimed": false, + "bundleCaptureHostUsedAsSupEvidence": false, + "counterpartReadyKeyKinds": [ + "updateId", + "ciId", + "contentId", + "updateJobId", + "clientSafeHandle", + "siteCode", + "supHostHandle" + ], + "emittedCounterpartReadyFact": false, + "counterpartReadyFacts": [] + }, + "prohibitedClaims": [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/manifest.json new file mode 100644 index 000000000..7c445562c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/manifest.json @@ -0,0 +1,71 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "workflow": "updates", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [ + { + "artifactId": "updates-supplemental-conflict-01-install", + "designOnlyCatalog": { + "entryId": "client-updates", + "groupMemberships": [ + "client-updates" + ] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "UpdatesHandler.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/UpdatesHandler.log", + "pathFingerprint": "synthetic:updates-supplemental-conflict-01-install", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T18:59:59Z", + "bytesCopied": 405, + "relativePath": "evidence/client-updates/current/UpdatesHandler.log" + }, + { + "artifactId": "updates-supplemental-conflict-02-cbs", + "designOnlyCatalog": { + "entryId": "client-windows-update-supplemental", + "groupMemberships": [ + "client-windows-update-supplemental" + ] + }, + "role": "client", + "kind": "cbsLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "originalBasename": "CBS.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/Windows/Logs/CBS/CBS.log", + "pathFingerprint": "synthetic:updates-supplemental-conflict-02-cbs", + "rotation": { + "kind": "current", + "fragmentComplete": true + }, + "sourceVersion": null, + "capturedUtc": "2026-07-30T18:59:59Z", + "bytesCopied": 117, + "relativePath": "evidence/client-windows-update-supplemental/current/CBS.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md new file mode 100644 index 000000000..22142500f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md @@ -0,0 +1,19 @@ +# SCCM production correlation contract fixtures + +These fixtures are the executable production contract for issue #333. They cover exactly three independently accepted pairs: + +- policy to Management Point (`#321` to `#328`); +- content to Distribution Point (`#322` to `#329`); +- updates to Software Update Point (`#323` to `#330`). + +`pair-registry.json` admits only those pairs. Each is `ruleValidated`, production-enabled, owned by `sccm::correlation`, and bound to all thirteen shared guards. The registry contains no compatibility aliases, pending pair state, or undeclared blocker. + +Each pair matrix is executed through the production reducer. It contains one healthy exact case and fourteen adversarial constructions covering the thirteen guards; the reordered-input guard uses opposite-order A/B cases. Every scenario pins: + +- outcome, link strength, and confidence; +- sorted reason codes and triggered guards; +- the SHA-256 hash of the complete serialized analysis. + +The shared guard set covers missing counterparts, same-time evidence without an exact key, conflicting exact keys, incompatible topology, version/profile mismatch, unknown profiles, invalid time ordering, partial or rotation-split capture, unrelated terminal failures, public-output redaction, and input reordering. Exact corroboration requires every guard to pass plus an exact compatible key, compatible topology, usable causal ordering, complete coverage/rotation, and a related terminal server failure. + +The correlation output contains only deterministic hashed fact handles, closed enums, and bounded logical artifact requests. Raw Windows paths, live hostnames, users, tenants, tokens, evidence messages, and source-local identifiers are not part of the public projection. Source analyses are borrowed immutably and their established serialized contracts remain unchanged. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/content_distribution_point/adversarial-matrix.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/content_distribution_point/adversarial-matrix.json new file mode 100644 index 000000000..1ff09e51c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/content_distribution_point/adversarial-matrix.json @@ -0,0 +1,204 @@ +{ + "schemaVersion": "1.0.0", + "pair": "contentDistributionPoint", + "scenarios": [ + { + "scenarioId": "content-healthy", + "mutation": "healthy", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "86389112bae52c1a2bbeef7f697492567968dbdf622216f6d0ea6b4003c4d050" + }, + { + "scenarioId": "content-conflicting-key", + "mutation": "conflictingExactKey", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict" + ], + "expectedTriggeredGuards": [ + "conflicting-exact-key" + ], + "expectedOutputSha256": "b5b601930465afff796f08d85ed246f2a110f8ab4266f7185a15fdc643b959e0" + }, + { + "scenarioId": "content-topology-mismatch", + "mutation": "incompatibleTopology", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "topology-mismatch" + ], + "expectedTriggeredGuards": [ + "incompatible-topology" + ], + "expectedOutputSha256": "4628070a37f0bc7463ccd5aa9969a2cd4104b915621dee223aefc185f4f6b1b8" + }, + { + "scenarioId": "content-invalid-offset", + "mutation": "invalidTimestampOffset", + "expectedOutcome": "notCausal", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "medium", + "expectedReasonCodes": [ + "ordering-unavailable" + ], + "expectedTriggeredGuards": [ + "invalid-timestamp-offset" + ], + "expectedOutputSha256": "836a042ef4ea3e143d99e3d9d18c1fcbade969ad1cd3d6bb4d5d0262718fba7b" + }, + { + "scenarioId": "content-client-only", + "mutation": "missingServerCounterpart", + "expectedOutcome": "counterpartRequested", + "expectedLinkStrength": "unlinked", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "server-counterpart-missing" + ], + "expectedTriggeredGuards": [ + "missing-server-counterpart" + ], + "expectedOutputSha256": "ed1df2c80beea6c274d80969e358d4019f431d898e260fcdda70afdc83cc45f2" + }, + { + "scenarioId": "content-server-only", + "mutation": "missingClientCounterpart", + "expectedOutcome": "counterpartRequested", + "expectedLinkStrength": "unlinked", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "client-counterpart-missing" + ], + "expectedTriggeredGuards": [ + "missing-client-counterpart" + ], + "expectedOutputSha256": "6feaf58475e9db6bd955bb737b7ccf139eb3fa108a379da94c251cb5748851bd" + }, + { + "scenarioId": "content-partial-capture", + "mutation": "partialCapture", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "partial-source-coverage" + ], + "expectedTriggeredGuards": [ + "partial-capture" + ], + "expectedOutputSha256": "bebd381f8582b28f01f77deec1d06b45d342ff125548674d52d95c823d32b408" + }, + { + "scenarioId": "content-redaction", + "mutation": "redactionBoundary", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "aa8a7be47b8f6c549c13dd9f4931dcc1145845cb14456505c596b82ab25e0195" + }, + { + "scenarioId": "content-reordered-input-a", + "mutation": "reorderedInputA", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "2a5799dc5aee79fe823efa879f3eb0ac36eda19ed68436e59407086ea59d3834" + }, + { + "scenarioId": "content-reordered-input-b", + "mutation": "reorderedInputB", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "2a5799dc5aee79fe823efa879f3eb0ac36eda19ed68436e59407086ea59d3834" + }, + { + "scenarioId": "content-rotation-split", + "mutation": "rotationSplit", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "rotation-incomplete" + ], + "expectedTriggeredGuards": [ + "rotation-split" + ], + "expectedOutputSha256": "c24696833c0315f587d13fc67c20c77babd14621cab4bbedc1089b2bbe4ddbc3" + }, + { + "scenarioId": "content-same-time-no-key", + "mutation": "sameTimeNoKey", + "expectedOutcome": "candidateOnly", + "expectedLinkStrength": "candidate", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict", + "same-time-without-exact-key" + ], + "expectedTriggeredGuards": [ + "conflicting-exact-key", + "same-time-no-key" + ], + "expectedOutputSha256": "3e67925e5a00f66ca2066ddd2368c6a3f1717646b5b25c375b0a4aeb7e9a7e31" + }, + { + "scenarioId": "content-unknown-profile", + "mutation": "unknownExtractionProfile", + "expectedOutcome": "profileGap", + "expectedLinkStrength": "candidate", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-unvalidated" + ], + "expectedTriggeredGuards": [ + "unknown-extraction-profile" + ], + "expectedOutputSha256": "18dd98da80a684ab7c41b6698bbe4a63e42a99a38371d3419c2b52c93f289507" + }, + { + "scenarioId": "content-unrelated-terminal-error", + "mutation": "unrelatedTerminalError", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict", + "unrelated-server-terminal" + ], + "expectedTriggeredGuards": [ + "conflicting-exact-key", + "unrelated-terminal-error" + ], + "expectedOutputSha256": "93521f8e12a33ce19659f1a0ce33c39145bc71f611db87f57fc9df9c8c419bac" + }, + { + "scenarioId": "content-version-mismatch", + "mutation": "versionMismatch", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-version-mismatch" + ], + "expectedTriggeredGuards": [ + "version-mismatch" + ], + "expectedOutputSha256": "9475b623ea0f594c15c3b669eb19ec76f47c2a8b7835752042475b3b7bd96305" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/pair-registry.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/pair-registry.json new file mode 100644 index 000000000..a139eca44 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/pair-registry.json @@ -0,0 +1,83 @@ +{ + "schemaVersion": "1.0.0", + "pairs": [ + { + "pairId": "content-distribution-point", + "workflow": "contentDistributionPoint", + "clientIssue": "#322", + "serverIssue": "#329", + "state": "ruleValidated", + "productionEnabled": true, + "ruleValidated": true, + "implementationModule": "sccm::correlation", + "requiredGuardIds": [ + "conflicting-exact-key", + "incompatible-topology", + "invalid-timestamp-offset", + "missing-client-counterpart", + "missing-server-counterpart", + "partial-capture", + "redaction-boundary", + "reordered-input", + "rotation-split", + "same-time-no-key", + "unknown-extraction-profile", + "unrelated-terminal-error", + "version-mismatch" + ], + "blockers": [] + }, + { + "pairId": "policy-management-point", + "workflow": "policyManagementPoint", + "clientIssue": "#321", + "serverIssue": "#328", + "state": "ruleValidated", + "productionEnabled": true, + "ruleValidated": true, + "implementationModule": "sccm::correlation", + "requiredGuardIds": [ + "conflicting-exact-key", + "incompatible-topology", + "invalid-timestamp-offset", + "missing-client-counterpart", + "missing-server-counterpart", + "partial-capture", + "redaction-boundary", + "reordered-input", + "rotation-split", + "same-time-no-key", + "unknown-extraction-profile", + "unrelated-terminal-error", + "version-mismatch" + ], + "blockers": [] + }, + { + "pairId": "updates-software-update-point", + "workflow": "updatesSoftwareUpdatePoint", + "clientIssue": "#323", + "serverIssue": "#330", + "state": "ruleValidated", + "productionEnabled": true, + "ruleValidated": true, + "implementationModule": "sccm::correlation", + "requiredGuardIds": [ + "conflicting-exact-key", + "incompatible-topology", + "invalid-timestamp-offset", + "missing-client-counterpart", + "missing-server-counterpart", + "partial-capture", + "redaction-boundary", + "reordered-input", + "rotation-split", + "same-time-no-key", + "unknown-extraction-profile", + "unrelated-terminal-error", + "version-mismatch" + ], + "blockers": [] + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/policy_management_point/adversarial-matrix.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/policy_management_point/adversarial-matrix.json new file mode 100644 index 000000000..b7592fa79 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/policy_management_point/adversarial-matrix.json @@ -0,0 +1,204 @@ +{ + "schemaVersion": "1.0.0", + "pair": "policyManagementPoint", + "scenarios": [ + { + "scenarioId": "policy-healthy", + "mutation": "healthy", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "c3ebc9911c021195611ffbdc8c497f906c7fadf60f5dae419da95eddaf47a46b" + }, + { + "scenarioId": "policy-conflicting-key", + "mutation": "conflictingExactKey", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict" + ], + "expectedTriggeredGuards": [ + "conflicting-exact-key" + ], + "expectedOutputSha256": "8f1dd8e33138013e162411a8c2d4221a346e4fbab113ba629c7b0846b8d34555" + }, + { + "scenarioId": "policy-topology-mismatch", + "mutation": "incompatibleTopology", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "topology-mismatch" + ], + "expectedTriggeredGuards": [ + "incompatible-topology" + ], + "expectedOutputSha256": "b2dadae5c089876db567510611254811ab277d17ddb0c7aae7737feb837ba5a5" + }, + { + "scenarioId": "policy-invalid-offset", + "mutation": "invalidTimestampOffset", + "expectedOutcome": "notCausal", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "medium", + "expectedReasonCodes": [ + "ordering-unavailable" + ], + "expectedTriggeredGuards": [ + "invalid-timestamp-offset" + ], + "expectedOutputSha256": "3a6fa01021ca34b579662cc598e4e78ffc2279699562a05abcf679123fe15434" + }, + { + "scenarioId": "policy-client-only", + "mutation": "missingServerCounterpart", + "expectedOutcome": "counterpartRequested", + "expectedLinkStrength": "unlinked", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "server-counterpart-missing" + ], + "expectedTriggeredGuards": [ + "missing-server-counterpart" + ], + "expectedOutputSha256": "61bc960e21763e7964293fa79b6508e28e65ac49a2172ea302c77e86536eb00e" + }, + { + "scenarioId": "policy-server-only", + "mutation": "missingClientCounterpart", + "expectedOutcome": "counterpartRequested", + "expectedLinkStrength": "unlinked", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "client-counterpart-missing" + ], + "expectedTriggeredGuards": [ + "missing-client-counterpart" + ], + "expectedOutputSha256": "6fba97248a83d7664f42c5fc5767225d394620b9f5791d91fa32e81d4ee42628" + }, + { + "scenarioId": "policy-partial-capture", + "mutation": "partialCapture", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "partial-source-coverage" + ], + "expectedTriggeredGuards": [ + "partial-capture" + ], + "expectedOutputSha256": "1484872bdf1783a11cd1d8e9f43218eea755ff3ed3dc8b9234a96137b43bff1a" + }, + { + "scenarioId": "policy-redaction", + "mutation": "redactionBoundary", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "997942ab5a47842cbe50e126e03151b8fd9a2843843b26635bc083e0b7b88521" + }, + { + "scenarioId": "policy-reordered-input-a", + "mutation": "reorderedInputA", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "7b9cc92b8084b0c61933c5fc606211e404c7f06dce9e742ae157d971589b2fd0" + }, + { + "scenarioId": "policy-reordered-input-b", + "mutation": "reorderedInputB", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "7b9cc92b8084b0c61933c5fc606211e404c7f06dce9e742ae157d971589b2fd0" + }, + { + "scenarioId": "policy-rotation-split", + "mutation": "rotationSplit", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "rotation-incomplete" + ], + "expectedTriggeredGuards": [ + "rotation-split" + ], + "expectedOutputSha256": "0e5cd3e0f384249e02cdb839ad211d2a450e29ccd6402a0bd570a1cdc98e1165" + }, + { + "scenarioId": "policy-same-time-no-key", + "mutation": "sameTimeNoKey", + "expectedOutcome": "candidateOnly", + "expectedLinkStrength": "candidate", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict", + "same-time-without-exact-key" + ], + "expectedTriggeredGuards": [ + "conflicting-exact-key", + "same-time-no-key" + ], + "expectedOutputSha256": "7b031625aebbd2f82edd3aefe6a0ea6a8ce50f991bcd3a2befe149d9110fc3c8" + }, + { + "scenarioId": "policy-unknown-profile", + "mutation": "unknownExtractionProfile", + "expectedOutcome": "profileGap", + "expectedLinkStrength": "candidate", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-unvalidated" + ], + "expectedTriggeredGuards": [ + "unknown-extraction-profile" + ], + "expectedOutputSha256": "2a114d06ca9156e924216e8cec28ae4afe238d4b801640218061718e478e46ac" + }, + { + "scenarioId": "policy-unrelated-terminal-error", + "mutation": "unrelatedTerminalError", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict", + "unrelated-server-terminal" + ], + "expectedTriggeredGuards": [ + "conflicting-exact-key", + "unrelated-terminal-error" + ], + "expectedOutputSha256": "ec6fb443597a7c99cdb90ef70f898039156a18bae55d3e22b9983a203bb3d4b7" + }, + { + "scenarioId": "policy-version-mismatch", + "mutation": "versionMismatch", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-version-mismatch" + ], + "expectedTriggeredGuards": [ + "version-mismatch" + ], + "expectedOutputSha256": "10f24737a3cbe41cb17529d9a1eef4786b24c8fb57cce2c57f9fa37533d7af55" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/shared/adversarial-matrix.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/shared/adversarial-matrix.json new file mode 100644 index 000000000..13829d93c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/shared/adversarial-matrix.json @@ -0,0 +1,231 @@ +{ + "schemaVersion": "1.0.0", + "guards": [ + { + "guardId": "conflicting-exact-key", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "incompatibilityReason", + "sourceLocalResults" + ] + }, + { + "guardId": "incompatible-topology", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "incompatibilityReason", + "sourceLocalResults" + ] + }, + { + "guardId": "invalid-timestamp-offset", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "orderingUnavailable", + "sourceLocalResults" + ] + }, + { + "guardId": "missing-client-counterpart", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "clientArtifactRequest", + "serverLocalResults" + ] + }, + { + "guardId": "missing-server-counterpart", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "clientLocalResults", + "serverArtifactRequest" + ] + }, + { + "guardId": "partial-capture", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "coverageGap", + "sourceLocalResults" + ] + }, + { + "guardId": "redaction-boundary", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [], + "forbiddenConfidences": [], + "requiredOutputs": [ + "publicSafeHandles", + "redactedProjection" + ] + }, + { + "guardId": "reordered-input", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [], + "forbiddenConfidences": [], + "requiredOutputs": [ + "deterministicSerialization", + "sourceLocalResults" + ] + }, + { + "guardId": "rotation-split", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "coverageGap", + "sourceLocalResults" + ] + }, + { + "guardId": "same-time-no-key", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "candidateSymptom", + "sourceLocalResults" + ] + }, + { + "guardId": "unknown-extraction-profile", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "profileGap", + "sourceLocalResults" + ] + }, + { + "guardId": "unrelated-terminal-error", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "sourceLocalResults", + "unlinkedTerminalEvidence" + ] + }, + { + "guardId": "version-mismatch", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "incompatibilityReason", + "sourceLocalResults" + ] + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/updates_software_update_point/adversarial-matrix.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/updates_software_update_point/adversarial-matrix.json new file mode 100644 index 000000000..311022c01 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/updates_software_update_point/adversarial-matrix.json @@ -0,0 +1,204 @@ +{ + "schemaVersion": "1.0.0", + "pair": "updatesSoftwareUpdatePoint", + "scenarios": [ + { + "scenarioId": "updates-healthy", + "mutation": "healthy", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "6209151d77bf897d4facc40d35d818971279b0a1233cce8380972fe15c0bb905" + }, + { + "scenarioId": "updates-conflicting-key", + "mutation": "conflictingExactKey", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict" + ], + "expectedTriggeredGuards": [ + "conflicting-exact-key" + ], + "expectedOutputSha256": "54f0a9cd9fabcd367f9a41bfec83dde43aaa98570b30ee6993538f481bf8d9f6" + }, + { + "scenarioId": "updates-topology-mismatch", + "mutation": "incompatibleTopology", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "topology-mismatch" + ], + "expectedTriggeredGuards": [ + "incompatible-topology" + ], + "expectedOutputSha256": "c82f2b5198d71a9a470e9db9d8c79dc81243ee33a595b15956b03141da4c3a67" + }, + { + "scenarioId": "updates-invalid-offset", + "mutation": "invalidTimestampOffset", + "expectedOutcome": "notCausal", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "medium", + "expectedReasonCodes": [ + "ordering-unavailable" + ], + "expectedTriggeredGuards": [ + "invalid-timestamp-offset" + ], + "expectedOutputSha256": "39b9d2615a07ffb603ceee9ea2f12b3a1a8651cc419ffea9aa2137022f7c5c7a" + }, + { + "scenarioId": "updates-client-only", + "mutation": "missingServerCounterpart", + "expectedOutcome": "counterpartRequested", + "expectedLinkStrength": "unlinked", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "server-counterpart-missing" + ], + "expectedTriggeredGuards": [ + "missing-server-counterpart" + ], + "expectedOutputSha256": "df411c0b3787d4895b7c90ce2dd975f5f2f5f974a969246a59d4f9cbb87dbbad" + }, + { + "scenarioId": "updates-server-only", + "mutation": "missingClientCounterpart", + "expectedOutcome": "counterpartRequested", + "expectedLinkStrength": "unlinked", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "client-counterpart-missing" + ], + "expectedTriggeredGuards": [ + "missing-client-counterpart" + ], + "expectedOutputSha256": "f734aa5280a74c73e2dbe4ed225fc1085e90fc89be31a88a2de2f651a5049262" + }, + { + "scenarioId": "updates-partial-capture", + "mutation": "partialCapture", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "partial-source-coverage" + ], + "expectedTriggeredGuards": [ + "partial-capture" + ], + "expectedOutputSha256": "132c4c17fa0c6116aec2311b561f81762094e94d7c7ec7adeb0035617cc28d9a" + }, + { + "scenarioId": "updates-redaction", + "mutation": "redactionBoundary", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "d30b6570a9d3b6f28d56fae8e616e94a380fcbc552673f2de07ef287a3678d9a" + }, + { + "scenarioId": "updates-reordered-input-a", + "mutation": "reorderedInputA", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "f3a5154447fb347009adcf451fb306b364fe9c1b07d82fbbaadecd0615fac85f" + }, + { + "scenarioId": "updates-reordered-input-b", + "mutation": "reorderedInputB", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "f3a5154447fb347009adcf451fb306b364fe9c1b07d82fbbaadecd0615fac85f" + }, + { + "scenarioId": "updates-rotation-split", + "mutation": "rotationSplit", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "rotation-incomplete" + ], + "expectedTriggeredGuards": [ + "rotation-split" + ], + "expectedOutputSha256": "99e2b1706471b9d73660c024d92ced448375544a4c56cf2873a4fe32a8f767c0" + }, + { + "scenarioId": "updates-same-time-no-key", + "mutation": "sameTimeNoKey", + "expectedOutcome": "candidateOnly", + "expectedLinkStrength": "candidate", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict", + "same-time-without-exact-key" + ], + "expectedTriggeredGuards": [ + "conflicting-exact-key", + "same-time-no-key" + ], + "expectedOutputSha256": "e9867dcc84a6d27c105b2f65222c34147624ebe5ee02309157bf3b2f6c9e27dd" + }, + { + "scenarioId": "updates-unknown-profile", + "mutation": "unknownExtractionProfile", + "expectedOutcome": "profileGap", + "expectedLinkStrength": "candidate", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-unvalidated" + ], + "expectedTriggeredGuards": [ + "unknown-extraction-profile" + ], + "expectedOutputSha256": "391959d259027be1b42dfea412c51c9e466516ba3ba72e8fc92c3501778e4375" + }, + { + "scenarioId": "updates-unrelated-terminal-error", + "mutation": "unrelatedTerminalError", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict", + "unrelated-server-terminal" + ], + "expectedTriggeredGuards": [ + "conflicting-exact-key", + "unrelated-terminal-error" + ], + "expectedOutputSha256": "657b98dd6291b6dc712e956e6a179353ccebf9abe9ac165e19885675e5fcfc2b" + }, + { + "scenarioId": "updates-version-mismatch", + "mutation": "versionMismatch", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-version-mismatch" + ], + "expectedTriggeredGuards": [ + "version-mismatch" + ], + "expectedOutputSha256": "05727fbf39e7f95a56378ff8fa101a8ccf3db33aa765f079109ff2dd7752cb2b" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md new file mode 100644 index 000000000..290069023 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md @@ -0,0 +1,103 @@ +# Synthetic SCCM server intake fixtures + +These fixtures prepare issue #335 while #318 owns the shared SCCM schema. A +focused Rust fixture-contract test enforces their site-code, rotation-path, and +byte-integrity invariants, but no production native reader consumes them yet. +Every value is synthetic, deterministic, and privacy-safe: + +- permitted topology host labels are `LAB-CM01`, `LAB-MP01`, and `LAB-DP01`; +- the exact synthetic three-character site code is `LAB`; +- raw source paths are replaced by `REDACTED_*` markers; +- configured roots use deterministic opaque `synthetic:path:*` fingerprints; +- every manifest declares `syntheticFixture: true` and `proposalOnly: true`; +- evidence contains no customer host, user, site, domain, identifier, + credential, certificate, URL, database name, or client key. + +Each scenario has `manifest.json` and `expected.json`. `Captured` and `Capped` +artifacts also have minimal evidence at the exact bundle-relative path named by +their manifest. `Absent`, `AccessDenied`, `Skipped`, and `Unsupported` +artifacts have a null/omitted `relativePath`, zero `bytesCopied`, and no +evidence placeholder. Artifact IDs are unique across every artifact in one +manifest/bundle, captured or non-captured; repeats across independent bundles +are permitted. IDs derive from canonical producer/source/subject/path/ +basename/rotation identity, never discovery order. Non-null relative paths are +also unique inside a bundle. Expected source lists use producer role/host, +source ID, workflow subject, path fingerprint, explicit rotation family/value, +lineage, basename, state, relative path, and artifact ID as a total order. This +serialization order does not imply record chronology. The expected data +documents only intake classification/coverage and never a role-health or +client-causality finding. + +Evidence payloads are raw and bundle-internal. Public/exported evidence and +derived values must cross the #318 redaction boundary; they may retain only +approved opaque handles and statuses, never raw paths, hosts, identifiers, or +unredacted content. + +The current preparation shape is provisional: + +- `captureState`, topology role names, evidence IDs, coverage output, rotation + syntax, and legacy mapping must be reconciled to #318 before implementation. +- `defaultCandidateState: "absentCandidateOnly"` means exactly that a default + candidate was not present. It must never be interpreted as an absent or + broken role. +- An unsupported source remains retained manifest evidence but is ineligible + for a role reducer. A capped/malformed rotation cannot yield terminal health. + +## Preparation validation + +Validation must parse every JSON file and then walk every manifest artifact: + +- `Captured`/`Capped`: `relativePath` is non-null, resolves beneath its + scenario directory, contains a `SYNTHETIC` marker, and its file byte length + equals `bytesCopied`. +- Every captured/capped artifact carries deterministic `encoding` and + `collectionLimit` provenance; expected data repeats those assertions. + Non-captured states omit those fields. +- A byte limit is inclusive and applies to raw source bytes before decoding. + A capped file is the exact prefix through `byteLimit`, without decode-first + splitting, repair, or replacement; its raw file size and `bytesCopied` both + equal the limit, with `truncated: true` and `fragmentComplete: false`. +- Complete captured records use all required CCM attributes (`time`, `date`, + `component`, `context`, `type`, `thread`, and `file`) and contain + `SYNTHETIC FIXTURE` inside the first record message. The deliberately capped + partial starts with a CCM prefix, contains the same marker, lacks terminal + framing, and produces zero complete/successful CCM records. +- `Absent`/`AccessDenied`/`Skipped`/`Unsupported`: `relativePath` is null or + omitted and `bytesCopied` is zero. +- Every file beneath a scenario's `evidence/` tree is referenced by exactly one + artifact. This rejects stale flat placeholders and unmanifested captures. +- Every admitted record with a valid offset has one authoritative normalized + UTC instant that never exceeds `collectedUtc` (zero synthetic tolerance). + A timestamped rotation filename/value is no later than that member's + earliest admitted record. Unknown/invalid offsets are non-comparable + coverage gaps: they are never assigned an invented UTC, reordered, or + correlated. +- Producer role/host topology is distinct from optional workflow subject. + `MP_GetAuth.log`, `MP_GetPolicy.log`, and `MP_Location.log` retain observed + MP/site-system placement; site-server-produced `mpcontrol.log` is a separate + catalog row. Ambiguous/co-located placement remains unresolved pending + native validation. Known site-server DP/SUP control logs cannot be relabeled + as DP/SUP producers. +- The configured-root collision scenario has two same-basename artifacts with + distinct fingerprints, opaque root segments, IDs, contents, and references. + The capped SUP path also carries deterministic subject-instance and root + discriminators. All identity keys/destinations are precomputed before + writes; destinations use atomic create/no-overwrite, and roots/instances + never normalize-merge. +- Rotation rank is timestamped, numbered, `.lo_`, current, provider-defined, + then none; timestamps ascend, numbers descend, and lineage/basename/state/ + relative-path/artifact-ID tie-breakers make reordering deterministic. +- Canonical artifact ordering, manifest-scoped unique IDs/paths, topology + privacy markers, top-level synthetic/proposal markers, redacted original + paths, and `synthetic:path:*` fingerprints remain stable. + +The local exact-byte coordinator parses all 22 JSON files, pressure-tests +within-manifest duplicate rejection and cross-bundle ID reuse, precomputes +identity/destination collisions, walks exact references/no-orphans, checks +privacy/producer/chronology/total-order contracts, and validates raw byte +counts before decoding. The ignored preparation report records its exact +command and result. These checks remain independent of the future #318 Rust +intake target. + +See `docs/sccm/preparation/issue-335-server-intake.md` for the source catalog, +native capture-adapter design, matrix, and exact #318 dependency decisions. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/missing-required-field/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/missing-required-field/expected.json new file mode 100644 index 000000000..3ff9b0a69 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/missing-required-field/expected.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": "1.0.0", + "valid": false, + "admittedToSemanticCatalog": false, + "issues": [ + "missingField:ownerIssue" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/missing-required-field/source-card.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/missing-required-field/source-card.json new file mode 100644 index 000000000..99c9210b5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/missing-required-field/source-card.json @@ -0,0 +1,72 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "missing-owner-source", + "cardVersion": "1.0.0", + "family": "Synthetic malformed source missing required ownership", + "roleScope": [ + "syntheticRole" + ], + "candidateBasenames": [ + "SyntheticRole.log" + ], + "pathClasses": [ + "configuredRoleLogRoot" + ], + "rawParserFamily": "ccm", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 1024, + "accessPolicy": "leastPrivilegeNoEscalation", + "rotationPolicy": { + "kinds": [ + "current" + ], + "maxFiles": 1 + } + }, + "privacy": { + "sensitivity": "low", + "classes": [], + "redactionRequired": false, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "roleScope" + ], + "rawSensitiveFieldProjection": [] + }, + "expectedHealthyEvidence": "A future validated synthetic rule would require an exact request key and explicit terminal success evidence.", + "terminalFailureEvidence": "A future validated synthetic rule would require an exact request key and explicit terminal failure evidence.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "promotion": { + "state": "candidate", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": null, + "deferredReason": null + }, + "semanticPolicy": { + "captureGuidanceOnly": true, + "canCreateTransactions": false, + "canCreateFailureFindings": false + }, + "nextEvidence": [ + "Add explicit issue ownership before this malformed source card can be accepted." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/redaction-required/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/redaction-required/expected.json new file mode 100644 index 000000000..1c56f1b30 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/redaction-required/expected.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": "1.0.0", + "valid": false, + "admittedToSemanticCatalog": false, + "issues": [ + "rawSensitiveProjectionForbidden", + "redactionRequired", + "unsafePublicProjection" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/redaction-required/source-card.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/redaction-required/source-card.json new file mode 100644 index 000000000..8b85c7909 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/redaction-required/source-card.json @@ -0,0 +1,79 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "unsafe-private-source", + "cardVersion": "1.0.0", + "family": "Synthetic private source with unsafe projection settings", + "roleScope": [ + "syntheticRole" + ], + "candidateBasenames": [ + "SyntheticPrivate.log" + ], + "pathClasses": [ + "configuredRoleLogRoot" + ], + "rawParserFamily": "ccm", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 1024, + "accessPolicy": "leastPrivilegeNoEscalation", + "rotationPolicy": { + "kinds": [ + "current" + ], + "maxFiles": 1 + } + }, + "privacy": { + "sensitivity": "high", + "classes": [ + "queryText", + "userIdentity" + ], + "redactionRequired": false, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "rawQueryText", + "roleScope" + ], + "rawSensitiveFieldProjection": [ + "rawQueryText" + ] + }, + "expectedHealthyEvidence": "A future validated synthetic rule would require an exact request key and explicit terminal success evidence.", + "terminalFailureEvidence": "A future validated synthetic rule would require an exact request key and explicit terminal failure evidence.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "ownerIssue": "#334", + "promotion": { + "state": "candidate", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": null, + "deferredReason": null + }, + "semanticPolicy": { + "captureGuidanceOnly": true, + "canCreateTransactions": false, + "canCreateFailureFindings": false + }, + "nextEvidence": [ + "Remove raw sensitive projection and require public-output redaction before accepting this source card." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/unvalidated-source/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/unvalidated-source/expected.json new file mode 100644 index 000000000..005c39403 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/unvalidated-source/expected.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": "1.0.0", + "valid": false, + "admittedToSemanticCatalog": false, + "issues": [ + "candidateMetadataInvalid", + "unvalidatedSourceCannotDiagnose" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/unvalidated-source/source-card.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/unvalidated-source/source-card.json new file mode 100644 index 000000000..e583af497 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/unvalidated-source/source-card.json @@ -0,0 +1,73 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "unvalidated-production-source", + "cardVersion": "1.0.0", + "family": "Synthetic unvalidated source attempting semantic admission", + "roleScope": [ + "syntheticRole" + ], + "candidateBasenames": [ + "SyntheticRole.log" + ], + "pathClasses": [ + "configuredRoleLogRoot" + ], + "rawParserFamily": "ccm", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 1024, + "accessPolicy": "leastPrivilegeNoEscalation", + "rotationPolicy": { + "kinds": [ + "current" + ], + "maxFiles": 1 + } + }, + "privacy": { + "sensitivity": "low", + "classes": [], + "redactionRequired": false, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "roleScope" + ], + "rawSensitiveFieldProjection": [] + }, + "expectedHealthyEvidence": "A future validated synthetic rule would require an exact request key and explicit terminal success evidence.", + "terminalFailureEvidence": "A future validated synthetic rule would require an exact request key and explicit terminal failure evidence.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "ownerIssue": "#334", + "promotion": { + "state": "candidate", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": "sccm.server.synthetic.reduce", + "deferredReason": null + }, + "semanticPolicy": { + "captureGuidanceOnly": false, + "canCreateTransactions": true, + "canCreateFailureFindings": true + }, + "nextEvidence": [ + "Remove semantic claims and collect the complete observed, fixture, key, and implementation evidence chain." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/valid/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/valid/expected.json new file mode 100644 index 000000000..3b6843e6b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/valid/expected.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": "1.0.0", + "valid": true, + "admittedToSemanticCatalog": false, + "issues": [] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/valid/source-card.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/valid/source-card.json new file mode 100644 index 000000000..cd2bd767f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/valid/source-card.json @@ -0,0 +1,73 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "valid-candidate-source", + "cardVersion": "1.0.0", + "family": "Synthetic valid candidate source for admission testing", + "roleScope": [ + "syntheticRole" + ], + "candidateBasenames": [ + "SyntheticRole.log" + ], + "pathClasses": [ + "configuredRoleLogRoot" + ], + "rawParserFamily": "ccm", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 1024, + "accessPolicy": "leastPrivilegeNoEscalation", + "rotationPolicy": { + "kinds": [ + "current" + ], + "maxFiles": 1 + } + }, + "privacy": { + "sensitivity": "low", + "classes": [], + "redactionRequired": false, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "roleScope" + ], + "rawSensitiveFieldProjection": [] + }, + "expectedHealthyEvidence": "A future validated synthetic rule would require an exact request key and an explicit terminal success record.", + "terminalFailureEvidence": "A future validated synthetic rule would require an exact request key and explicit terminal failure evidence.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "ownerIssue": "#334", + "promotion": { + "state": "candidate", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": null, + "deferredReason": null + }, + "semanticPolicy": { + "captureGuidanceOnly": true, + "canCreateTransactions": false, + "canCreateFailureFindings": false + }, + "nextEvidence": [ + "Record sanitized role, configured-path, and version provenance before promoting this synthetic source." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/certificate-enrollment-pki.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/certificate-enrollment-pki.json new file mode 100644 index 000000000..63506c8df --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/certificate-enrollment-pki.json @@ -0,0 +1,80 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "certificate-enrollment-pki", + "cardVersion": "1.0.0", + "family": "Certificate enrollment and PKI role diagnostics", + "roleScope": [ + "certificateRegistrationPoint" + ], + "candidateBasenames": [ + "crp.log" + ], + "pathClasses": [ + "configuredRoleLogRoot", + "siteServerLogs" + ], + "rawParserFamily": "ccm", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 4194304, + "accessPolicy": "leastPrivilegeNoEscalation", + "rotationPolicy": { + "kinds": [ + "current", + "lo_" + ], + "maxFiles": 2 + } + }, + "privacy": { + "sensitivity": "high", + "classes": [ + "certificateIdentity", + "deviceIdentity", + "subjectName" + ], + "redactionRequired": true, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "roleScope" + ], + "rawSensitiveFieldProjection": [] + }, + "expectedHealthyEvidence": "A future validated rule must cite a bounded enrollment request and its terminal certificate-registration disposition from the same role instance.", + "terminalFailureEvidence": "A future terminal rule must cite an explicit certificate-registration failure and must not infer failure from a missing file, access denial, or time proximity.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "ownerIssue": "#334", + "promotion": { + "state": "candidate", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": null, + "deferredReason": null + }, + "semanticPolicy": { + "captureGuidanceOnly": true, + "canCreateTransactions": false, + "canCreateFailureFindings": false + }, + "nextEvidence": [ + "Record sanitized configured-role and source-version provenance from an authorized development server.", + "Design success, terminal-failure, privacy, incomplete-capture, and rotation fixtures before proposing a reducer." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/client-notification-bgb.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/client-notification-bgb.json new file mode 100644 index 000000000..feb1693a2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/client-notification-bgb.json @@ -0,0 +1,81 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "client-notification-bgb", + "cardVersion": "1.0.0", + "family": "Server-side client notification and BGB diagnostics", + "roleScope": [ + "clientNotificationServer", + "managementPoint" + ], + "candidateBasenames": [ + "BgbServer.log" + ], + "pathClasses": [ + "configuredRoleLogRoot", + "siteServerLogs" + ], + "rawParserFamily": "ccm", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 4194304, + "accessPolicy": "leastPrivilegeNoEscalation", + "rotationPolicy": { + "kinds": [ + "current", + "lo_" + ], + "maxFiles": 2 + } + }, + "privacy": { + "sensitivity": "high", + "classes": [ + "deviceIdentity", + "notificationPayload", + "userIdentity" + ], + "redactionRequired": true, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "roleScope" + ], + "rawSensitiveFieldProjection": [] + }, + "expectedHealthyEvidence": "A future validated rule must cite a server-side notification request and terminal acknowledgement with an exact, sanitized notification key.", + "terminalFailureEvidence": "A future terminal rule must cite an explicit server-side rejection or delivery exhaustion and distinguish it from client-side notification evidence.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "ownerIssue": "#334", + "promotion": { + "state": "candidate", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": null, + "deferredReason": null + }, + "semanticPolicy": { + "captureGuidanceOnly": true, + "canCreateTransactions": false, + "canCreateFailureFindings": false + }, + "nextEvidence": [ + "Record sanitized server-role, configured-path, and source-version provenance without assuming the candidate basename exists.", + "Validate server versus client notification keys using independent synthetic fixtures before defining any correlation." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/cloud-service-connection.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/cloud-service-connection.json new file mode 100644 index 000000000..a94e1d4f6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/cloud-service-connection.json @@ -0,0 +1,83 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "cloud-service-connection", + "cardVersion": "1.0.0", + "family": "Cloud and service-connection role diagnostics", + "roleScope": [ + "cloudManagementGatewayConnectionPoint", + "serviceConnectionPoint" + ], + "candidateBasenames": [ + "CloudMgr.log", + "SMS_Cloud_ProxyConnector.log" + ], + "pathClasses": [ + "configuredRoleLogRoot", + "siteServerLogs" + ], + "rawParserFamily": "ccm", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 4194304, + "accessPolicy": "leastPrivilegeNoEscalation", + "rotationPolicy": { + "kinds": [ + "current", + "lo_" + ], + "maxFiles": 2 + } + }, + "privacy": { + "sensitivity": "high", + "classes": [ + "certificateIdentity", + "cloudEndpoint", + "tenantIdentity", + "tokenMaterial" + ], + "redactionRequired": true, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "roleScope" + ], + "rawSensitiveFieldProjection": [] + }, + "expectedHealthyEvidence": "A future validated rule must cite a configured cloud operation and explicit terminal service response while retaining only redacted endpoint identity.", + "terminalFailureEvidence": "A future terminal rule must cite a corroborated service-connection failure; connectivity gaps, skipped collection, and timestamps alone remain coverage states.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "ownerIssue": "#334", + "promotion": { + "state": "candidate", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": null, + "deferredReason": null + }, + "semanticPolicy": { + "captureGuidanceOnly": true, + "canCreateTransactions": false, + "canCreateFailureFindings": false + }, + "nextEvidence": [ + "Observe an authorized configured role and capture only sanitized basename, path-class, version, and coverage provenance.", + "Complete privacy review for tenant, endpoint, certificate, token, and user-like data before authoring scenario fixtures." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/osd-pxe.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/osd-pxe.json new file mode 100644 index 000000000..42c0ed6f4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/osd-pxe.json @@ -0,0 +1,82 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "osd-pxe", + "cardVersion": "1.0.0", + "family": "Operating-system deployment and PXE role diagnostics", + "roleScope": [ + "distributionPointPxe", + "siteServer" + ], + "candidateBasenames": [ + "smspxe.log" + ], + "pathClasses": [ + "configuredRoleLogRoot", + "siteServerLogs" + ], + "rawParserFamily": "ccm", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 4194304, + "accessPolicy": "leastPrivilegeNoEscalation", + "rotationPolicy": { + "kinds": [ + "current", + "lo_" + ], + "maxFiles": 2 + } + }, + "privacy": { + "sensitivity": "high", + "classes": [ + "deviceIdentity", + "macAddress", + "networkIdentity", + "resourceIdentity" + ], + "redactionRequired": true, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "roleScope" + ], + "rawSensitiveFieldProjection": [] + }, + "expectedHealthyEvidence": "A future validated rule must cite an exact PXE request identity, compatible distribution-point topology, and explicit boot-service disposition.", + "terminalFailureEvidence": "A future terminal rule must cite an explicit PXE rejection or terminal service error and cannot infer failure from an absent default path.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "ownerIssue": "#334", + "promotion": { + "state": "candidate", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": null, + "deferredReason": null + }, + "semanticPolicy": { + "captureGuidanceOnly": true, + "canCreateTransactions": false, + "canCreateFailureFindings": false + }, + "nextEvidence": [ + "Record configured PXE-role and path-class provenance from a sanitized authorized server before asserting source availability.", + "Design privacy-safe success, rejection, topology-mismatch, rotation, malformed, and incomplete source fixtures." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/reporting.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/reporting.json new file mode 100644 index 000000000..db3ca243e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/reporting.json @@ -0,0 +1,82 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "reporting", + "cardVersion": "1.0.0", + "family": "Reporting services role diagnostics", + "roleScope": [ + "reportingServicesPoint" + ], + "candidateBasenames": [ + "srsrp.log" + ], + "pathClasses": [ + "configuredRoleLogRoot", + "reportServerLogs", + "siteServerLogs" + ], + "rawParserFamily": "ccm", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 4194304, + "accessPolicy": "leastPrivilegeNoEscalation", + "rotationPolicy": { + "kinds": [ + "current", + "lo_" + ], + "maxFiles": 2 + } + }, + "privacy": { + "sensitivity": "high", + "classes": [ + "dataSourceIdentity", + "reportIdentity", + "serviceAccountIdentity", + "userIdentity" + ], + "redactionRequired": true, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "roleScope" + ], + "rawSensitiveFieldProjection": [] + }, + "expectedHealthyEvidence": "A future validated rule must cite a reporting-role operation and explicit terminal configuration or service outcome from a compatible role instance.", + "terminalFailureEvidence": "A future terminal rule must cite an explicit reporting-role failure while keeping queries, report names, accounts, and data-source details private.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "ownerIssue": "#334", + "promotion": { + "state": "candidate", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": null, + "deferredReason": null + }, + "semanticPolicy": { + "captureGuidanceOnly": true, + "canCreateTransactions": false, + "canCreateFailureFindings": false + }, + "nextEvidence": [ + "Observe a configured reporting role and record sanitized source-version and configured-path provenance.", + "Define bounded redaction fixtures for report, query, account, endpoint, and data-source identity before any semantic rule." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/sql-database-export.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/sql-database-export.json new file mode 100644 index 000000000..294c9223b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/sql-database-export.json @@ -0,0 +1,79 @@ +{ + "cardSchemaVersion": "1.0.0", + "cardId": "sql-database-export", + "cardVersion": "1.0.0", + "family": "SQL database and supplementary export diagnostics", + "roleScope": [ + "siteDatabaseServer" + ], + "candidateBasenames": [ + "site-database-export.json" + ], + "pathClasses": [ + "operatorProvidedDatabaseSupplement" + ], + "rawParserFamily": "unsupported", + "sourceVersionScope": { + "state": "unknown", + "allowedPrefixes": [] + }, + "capture": { + "classification": "optional", + "maxBytes": 1048576, + "accessPolicy": "explicitOperatorExport", + "rotationPolicy": { + "kinds": [ + "snapshot" + ], + "maxFiles": 1 + } + }, + "privacy": { + "sensitivity": "high", + "classes": [ + "databaseIdentity", + "deviceIdentity", + "queryText", + "userIdentity" + ], + "redactionRequired": true, + "publicProjection": [ + "captureState", + "cardId", + "coverageState", + "roleScope" + ], + "rawSensitiveFieldProjection": [] + }, + "expectedHealthyEvidence": "No healthy semantic evidence is supported in this phase; an explicit operator export may only be retained as bounded coverage data.", + "terminalFailureEvidence": "No terminal database finding is supported in this phase; missing, denied, malformed, or partial exports remain coverage states only.", + "correlationPolicy": { + "keyState": "unvalidated", + "allowedKeyKinds": [], + "timeOnlyEligible": false, + "topologyRequired": true + }, + "fixtureIds": [], + "ownerIssue": "#334", + "promotion": { + "state": "deferred", + "observedEvidenceIds": [], + "implementationIssue": null, + "productionReducer": null, + "deferredReason": "Database and export parsing needs a separately approved source contract, privacy model, and bounded operator workflow." + }, + "semanticPolicy": { + "captureGuidanceOnly": true, + "canCreateTransactions": false, + "canCreateFailureFindings": false + }, + "nextEvidence": [ + "Approve a dedicated database-supplement contract with data minimization and explicit operator authorization.", + "Create sanitized schema, privacy, cap, unsupported-version, malformed, and partial-export fixtures in a separate implementation issue." + ], + "supersession": { + "state": "active", + "supersedes": [], + "supersededBy": null + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md new file mode 100644 index 000000000..3962c1bec --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md @@ -0,0 +1,34 @@ +# Synthetic Distribution Point fixture corpus + +This directory is test-only input for Issue `#329`. + +Each site-server artifact has one sealed Distribution Point workflow subject. +The multi-DP scenario therefore uses separate synthetic captures per subject; +one physical artifact never becomes authority for two DPs. + +- Every evidence file is authored synthetic CCM text and contains the literal + `SYNTHETIC FIXTURE` marker. +- `manifest.json` records physical producer, workflow subject, coverage, + rotation, bounded path, encoding, and exact byte-count provenance. +- `expected.json` is the frozen whole-output oracle from the exported analyzer. +- Exact package/content/version/DP/extraction-profile keys keep versions and DPs + independent. +- Case-folded path fingerprints stay unique, sanitized roots and rotated + basenames stay synthetic, and topology arrays retain only typed declared + handles. +- Rotation lineage/fragment fields, observation IDs, evidence references, and + coverage-gap IDs fail closed on malformed, empty, duplicate, or reused + values. +- Every source-local observation cites a nonempty closed array of exact raw + physical artifact/line ranges. Citing a fragment or malformed raw line does + not promote it to a logical transaction or make it correlation-eligible. +- Missing, denied, malformed, capped, or split evidence is coverage only. +- Client records and timestamps alone never establish a DP transaction or + cross-side cause. + +The preparation manifests retain the canonical intake contract's required +`proposalOnly` synthetic marker. It is never emitted as an analyzer claim. +The focused Rust contract resolves every manifest path, runs captured CCM +files through the existing SCCM logical-record envelope, verifies normalized +timestamp/line provenance, compares every output field, and rejects +adversarial mutations. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/absent-dp/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/absent-dp/expected.json new file mode 100644 index 000000000..70d5d6fca --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/absent-dp/expected.json @@ -0,0 +1,50 @@ +{ + "artifactRequests": [ + { + "logicalId": "distmgr", + "reason": "Collect the complete distmgr.log file.", + "role": "siteServer" + }, + { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + }, + { + "logicalId": "smsDpProv", + "reason": "Collect the complete SMSDPProv.log file.", + "role": "distributionPoint" + } + ], + "coverageGaps": [ + { + "artifactIds": [ + "dp-absent-02-provider" + ], + "producerRole": "distributionPoint", + "reason": "Distribution Point source coverage is absent; recollect the declared source without changing its state.", + "sourceId": "server-dp-distribution", + "state": "absent", + "workflowSubjectRole": null + }, + { + "artifactIds": [ + "dp-absent-01-distmgr" + ], + "producerRole": "siteServer", + "reason": "Distribution Point source coverage is absent; recollect the declared source without changing its state.", + "sourceId": "server-dp-distribution", + "state": "absent", + "workflowSubjectRole": "distributionPoint" + } + ], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/absent-dp/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/absent-dp/manifest.json new file mode 100644 index 000000000..f4ab8b390 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/absent-dp/manifest.json @@ -0,0 +1,41 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-absent-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://default-site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:absent-distmgr", + "rotation": {"kind": "current", "lineageId": "absent-distmgr"}, + "captureState": "absent", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z" + }, + { + "artifactId": "dp-absent-02-provider", + "sourceId": "server-dp-distribution", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "SMSDPProv.log", + "sanitizedSourcePath": "SYNTHETIC://default-dp-root/Logs/SMSDPProv.log", + "pathFingerprint": "synthetic:absent-provider", + "rotation": {"kind": "current", "lineageId": "absent-provider"}, + "captureState": "absent", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/evidence/server-dp-distribution/site/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/evidence/server-dp-distribution/site/current/PkgXferMgr.log new file mode 100644 index 000000000..b6adc20c1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/evidence/server-dp-distribution/site/current/PkgXferMgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..e17722939 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/expected.json new file mode 100644 index 000000000..e4bdd9a58 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/expected.json @@ -0,0 +1,124 @@ +{ + "artifactRequests": [ + { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + } + ], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "blockedOrDeferred", + "confidence": "medium", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-backlog-blocked-01-distmgr", + "entryId": "dp-backlog-blocked-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-backlog-blocked-01-distmgr", + "entryId": "dp-backlog-blocked-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-backlog-blocked-02-pkgxfer", + "entryId": "dp-backlog-blocked-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "contentId": "content-theta", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00008", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "distribute", + "nextArtifact": { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + }, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-backlog-blocked-01-distmgr", + "entryId": "dp-backlog-blocked-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:00.000", + "utcMillis": 1785412980000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-backlog-blocked-01-distmgr", + "entryId": "dp-backlog-blocked-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:01.000", + "utcMillis": 1785412981000 + } + }, + { + "disposition": "blocked", + "evidence": { + "artifactId": "dp-backlog-blocked-02-pkgxfer", + "entryId": "dp-backlog-blocked-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:02.000", + "utcMillis": 1785412982000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Warning", + "state": "blocked", + "stopPhase": "transfer", + "terminalEvidence": [], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00008:content=content-theta:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/manifest.json new file mode 100644 index 000000000..8d8834e4a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/manifest.json @@ -0,0 +1,49 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-backlog-blocked-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:backlog-blocked-distmgr", + "rotation": {"kind": "current", "lineageId": "backlog-blocked-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 642, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-backlog-blocked-02-pkgxfer", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:backlog-blocked-pkgxfer", + "rotation": {"kind": "current", "lineageId": "backlog-blocked-pkgxfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 321, + "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/dp-02/current/SMSDPProv.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/dp-02/current/SMSDPProv.log new file mode 100644 index 000000000..7360a78c5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/dp-02/current/SMSDPProv.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/dp/current/SMSDPProv.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/dp/current/SMSDPProv.log new file mode 100644 index 000000000..5a7f28687 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/dp/current/SMSDPProv.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site-dp02/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site-dp02/current/PkgXferMgr.log new file mode 100644 index 000000000..521176a3d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site-dp02/current/PkgXferMgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site-dp02/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site-dp02/current/distmgr.log new file mode 100644 index 000000000..63af35ce4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site-dp02/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/current/PkgXferMgr.log new file mode 100644 index 000000000..ad573c57e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/current/PkgXferMgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..578de33ea --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/expected.json new file mode 100644 index 000000000..cbd5af777 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/expected.json @@ -0,0 +1,420 @@ +{ + "artifactRequests": [ + { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + } + ], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "success", + "confidence": "medium", + "contentVersionMismatch": true, + "evidence": [ + { + "artifactId": "dp-version-01-distmgr", + "entryId": "dp-version-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-version-01-distmgr", + "entryId": "dp-version-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-version-02-pkgxfer", + "entryId": "dp-version-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-version-03-provider", + "entryId": "dp-version-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-version-03-provider", + "entryId": "dp-version-03-provider:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "contentId": "content-epsilon", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00005", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "makeAvailable", + "nextArtifact": null, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-01-distmgr", + "entryId": "dp-version-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:05:00.000", + "utcMillis": 1785413100000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-01-distmgr", + "entryId": "dp-version-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:05:01.000", + "utcMillis": 1785413101000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-02-pkgxfer", + "entryId": "dp-version-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:05:02.000", + "utcMillis": 1785413102000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-03-provider", + "entryId": "dp-version-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "validate", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:05:03.000", + "utcMillis": 1785413103000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-03-provider", + "entryId": "dp-version-03-provider:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "makeAvailable", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:05:04.000", + "utcMillis": 1785413104000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Success", + "state": "succeeded", + "stopPhase": null, + "terminalEvidence": [], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00005:content=content-epsilon:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + }, + { + "classification": "success", + "confidence": "medium", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-version-05-distmgr-dp02", + "entryId": "dp-version-05-distmgr-dp02:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-version-05-distmgr-dp02", + "entryId": "dp-version-05-distmgr-dp02:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-version-06-pkgxfer-dp02", + "entryId": "dp-version-06-pkgxfer-dp02:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-version-04-provider-dp02", + "entryId": "dp-version-04-provider-dp02:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-version-04-provider-dp02", + "entryId": "dp-version-04-provider-dp02:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "contentId": "content-epsilon", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-02", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00005", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "makeAvailable", + "nextArtifact": null, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-05-distmgr-dp02", + "entryId": "dp-version-05-distmgr-dp02:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:10:00.000", + "utcMillis": 1785413400000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-05-distmgr-dp02", + "entryId": "dp-version-05-distmgr-dp02:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:10:01.000", + "utcMillis": 1785413401000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-06-pkgxfer-dp02", + "entryId": "dp-version-06-pkgxfer-dp02:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:10:02.000", + "utcMillis": 1785413402000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-04-provider-dp02", + "entryId": "dp-version-04-provider-dp02:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "validate", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:10:03.000", + "utcMillis": 1785413403000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-04-provider-dp02", + "entryId": "dp-version-04-provider-dp02:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "makeAvailable", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:10:04.000", + "utcMillis": 1785413404000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Success", + "state": "succeeded", + "stopPhase": null, + "terminalEvidence": [], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00005:content=content-epsilon:content-version=1:dp=synthetic:subject:dp-02:profile=dp-server-5.00.test-v1:profile-version=1" + }, + { + "classification": "blockedOrDeferred", + "confidence": "medium", + "contentVersionMismatch": true, + "evidence": [ + { + "artifactId": "dp-version-01-distmgr", + "entryId": "dp-version-01-distmgr:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "dp-version-01-distmgr", + "entryId": "dp-version-01-distmgr:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + { + "artifactId": "dp-version-02-pkgxfer", + "entryId": "dp-version-02-pkgxfer:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "contentId": "content-epsilon", + "contentVersion": 2, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00005", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "distribute", + "nextArtifact": { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + }, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-01-distmgr", + "entryId": "dp-version-01-distmgr:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:15:00.000", + "utcMillis": 1785413700000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-version-01-distmgr", + "entryId": "dp-version-01-distmgr:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:15:01.000", + "utcMillis": 1785413701000 + } + }, + { + "disposition": "retrying", + "evidence": { + "artifactId": "dp-version-02-pkgxfer", + "entryId": "dp-version-02-pkgxfer:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:15:02.000", + "utcMillis": 1785413702000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Warning", + "state": "retrying", + "stopPhase": "transfer", + "terminalEvidence": [], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00005:content=content-epsilon:content-version=2:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/manifest.json new file mode 100644 index 000000000..29132a75c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/manifest.json @@ -0,0 +1,138 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": { + "siteCode": "LAB", + "distributionPointHandle": "safe:dp:lab-dp-01", + "distributionPointHandles": ["safe:dp:lab-dp-01", "safe:dp:lab-dp-02"], + "rolesObserved": ["distributionPoint", "siteServer"] + }, + "artifacts": [ + { + "artifactId": "dp-version-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "workflowSubjectBasis": "manifestTopology", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:version-distmgr", + "rotation": {"kind": "current", "lineageId": "version-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 1292, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-version-02-pkgxfer", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "workflowSubjectBasis": "manifestTopology", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:version-pkgxfer", + "rotation": {"kind": "current", "lineageId": "version-pkgxfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 649, + "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" + }, + { + "artifactId": "dp-version-03-provider", + "sourceId": "server-dp-distribution", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "SMSDPProv.log", + "sanitizedSourcePath": "SYNTHETIC://dp-root/Logs/SMSDPProv.log", + "pathFingerprint": "synthetic:version-provider", + "rotation": {"kind": "current", "lineageId": "version-provider", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 651, + "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" + }, + { + "artifactId": "dp-version-04-provider-dp02", + "sourceId": "server-dp-distribution", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-02", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-02", + "sourceKind": "ccmLog", + "originalBasename": "SMSDPProv.log", + "sanitizedSourcePath": "SYNTHETIC://dp-02-root/Logs/SMSDPProv.log", + "pathFingerprint": "synthetic:version-provider-dp02", + "rotation": {"kind": "current", "lineageId": "version-provider-dp02", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 651, + "relativePath": "evidence/server-dp-distribution/dp-02/current/SMSDPProv.log" + }, + { + "artifactId": "dp-version-05-distmgr-dp02", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-02", + "workflowSubjectBasis": "manifestTopology", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:version-distmgr-dp02", + "rotation": {"kind": "current", "lineageId": "version-distmgr-dp02", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 646, + "relativePath": "evidence/server-dp-distribution/site-dp02/current/distmgr.log" + }, + { + "artifactId": "dp-version-06-pkgxfer-dp02", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-02", + "workflowSubjectBasis": "manifestTopology", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:version-pkgxfer-dp02", + "rotation": {"kind": "current", "lineageId": "version-pkgxfer-dp02", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 325, + "relativePath": "evidence/server-dp-distribution/site-dp02/current/PkgXferMgr.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/dp/current/SMSDPProv.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/dp/current/SMSDPProv.log new file mode 100644 index 000000000..e02624545 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/dp/current/SMSDPProv.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/site/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/site/current/PkgXferMgr.log new file mode 100644 index 000000000..75f409b3a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/site/current/PkgXferMgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..50211bc93 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/expected.json new file mode 100644 index 000000000..22653652c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/expected.json @@ -0,0 +1,193 @@ +{ + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "success", + "confidence": "medium", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-recovery-01-distmgr", + "entryId": "dp-recovery-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-recovery-01-distmgr", + "entryId": "dp-recovery-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-recovery-02-pkgxfer", + "entryId": "dp-recovery-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-recovery-02-pkgxfer", + "entryId": "dp-recovery-02-pkgxfer:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-recovery-03-provider", + "entryId": "dp-recovery-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-recovery-03-provider", + "entryId": "dp-recovery-03-provider:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "contentId": "content-kappa", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00010", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "makeAvailable", + "nextArtifact": null, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-recovery-01-distmgr", + "entryId": "dp-recovery-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:00.000", + "utcMillis": 1785412800000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-recovery-01-distmgr", + "entryId": "dp-recovery-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:01.000", + "utcMillis": 1785412801000 + } + }, + { + "disposition": "failed", + "evidence": { + "artifactId": "dp-recovery-02-pkgxfer", + "entryId": "dp-recovery-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:01.500", + "utcMillis": 1785412801500 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-recovery-02-pkgxfer", + "entryId": "dp-recovery-02-pkgxfer:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:02.000", + "utcMillis": 1785412802000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-recovery-03-provider", + "entryId": "dp-recovery-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "validate", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:03.000", + "utcMillis": 1785412803000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-recovery-03-provider", + "entryId": "dp-recovery-03-provider:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "makeAvailable", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:04.000", + "utcMillis": 1785412804000 + } + } + ], + "recovered": true, + "scope": "distributionPointContent", + "severity": "Success", + "state": "succeeded", + "stopPhase": null, + "terminalEvidence": [ + { + "artifactId": "dp-recovery-02-pkgxfer", + "entryId": "dp-recovery-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00010:content=content-kappa:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/manifest.json new file mode 100644 index 000000000..ad169c7f6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/manifest.json @@ -0,0 +1,78 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "distributionPoint", + "capturedUtc": "2026-07-30T12:30:00Z" + }, + "topology": { + "siteCode": "LAB", + "distributionPointHandle": "safe:dp:lab-dp-01", + "distributionPointHandles": ["safe:dp:lab-dp-01"], + "rolesObserved": ["distributionPoint", "siteServer"] + }, + "artifacts": [ + { + "artifactId": "dp-recovery-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:recovery-distmgr", + "rotation": {"kind": "current", "lineageId": "recovery-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 642, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-recovery-02-pkgxfer", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:recovery-pkgxfer", + "rotation": {"kind": "current", "lineageId": "recovery-pkgxfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 642, + "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" + }, + { + "artifactId": "dp-recovery-03-provider", + "sourceId": "server-dp-distribution", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "SMSDPProv.log", + "sanitizedSourcePath": "SYNTHETIC://dp-root/Logs/SMSDPProv.log", + "pathFingerprint": "synthetic:recovery-provider", + "rotation": {"kind": "current", "lineageId": "recovery-provider", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 647, + "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..eae2b0212 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/expected.json new file mode 100644 index 000000000..3394ffeb1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/expected.json @@ -0,0 +1,97 @@ +{ + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "confirmedFailure", + "confidence": "high", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-distribution-failure-01-distmgr", + "entryId": "dp-distribution-failure-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-distribution-failure-01-distmgr", + "entryId": "dp-distribution-failure-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "contentId": "content-beta", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00002", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "receiveContent", + "nextArtifact": null, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-distribution-failure-01-distmgr", + "entryId": "dp-distribution-failure-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:02:00.000", + "utcMillis": 1785412920000 + } + }, + { + "disposition": "failed", + "evidence": { + "artifactId": "dp-distribution-failure-01-distmgr", + "entryId": "dp-distribution-failure-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:02:01.000", + "utcMillis": 1785412921000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Error", + "state": "failed", + "stopPhase": "distribute", + "terminalEvidence": [ + { + "artifactId": "dp-distribution-failure-01-distmgr", + "entryId": "dp-distribution-failure-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00002:content=content-beta:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/manifest.json new file mode 100644 index 000000000..473a55ecd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/manifest.json @@ -0,0 +1,29 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-distribution-failure-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:distribution-failure", + "rotation": {"kind": "current", "lineageId": "distribution-failure", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 636, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/dp/current/SMSDPProv.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/dp/current/SMSDPProv.log new file mode 100644 index 000000000..7d0ca1e39 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/dp/current/SMSDPProv.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/site/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/site/current/PkgXferMgr.log new file mode 100644 index 000000000..9f0226aaa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/site/current/PkgXferMgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..7ef064157 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/expected.json new file mode 100644 index 000000000..88f63f1d4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/expected.json @@ -0,0 +1,162 @@ +{ + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "success", + "confidence": "medium", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-healthy-01-distmgr", + "entryId": "dp-healthy-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-healthy-01-distmgr", + "entryId": "dp-healthy-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-healthy-02-pkgxfer", + "entryId": "dp-healthy-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-healthy-03-provider", + "entryId": "dp-healthy-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-healthy-03-provider", + "entryId": "dp-healthy-03-provider:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "contentId": "content-alpha", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00001", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "makeAvailable", + "nextArtifact": null, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-healthy-01-distmgr", + "entryId": "dp-healthy-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:00.000", + "utcMillis": 1785412800000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-healthy-01-distmgr", + "entryId": "dp-healthy-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:01.000", + "utcMillis": 1785412801000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-healthy-02-pkgxfer", + "entryId": "dp-healthy-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:02.000", + "utcMillis": 1785412802000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-healthy-03-provider", + "entryId": "dp-healthy-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "validate", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:03.000", + "utcMillis": 1785412803000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-healthy-03-provider", + "entryId": "dp-healthy-03-provider:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "makeAvailable", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:00:04.000", + "utcMillis": 1785412804000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Success", + "state": "succeeded", + "stopPhase": null, + "terminalEvidence": [], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00001:content=content-alpha:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/manifest.json new file mode 100644 index 000000000..c376523cf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/manifest.json @@ -0,0 +1,78 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "distributionPoint", + "capturedUtc": "2026-07-30T12:30:00Z" + }, + "topology": { + "siteCode": "LAB", + "distributionPointHandle": "safe:dp:lab-dp-01", + "distributionPointHandles": ["safe:dp:lab-dp-01"], + "rolesObserved": ["distributionPoint", "siteServer"] + }, + "artifacts": [ + { + "artifactId": "dp-healthy-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:healthy-distmgr", + "rotation": {"kind": "current", "lineageId": "healthy-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 642, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-healthy-02-pkgxfer", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:healthy-pkgxfer", + "rotation": {"kind": "current", "lineageId": "healthy-pkgxfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 323, + "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" + }, + { + "artifactId": "dp-healthy-03-provider", + "sourceId": "server-dp-distribution", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "SMSDPProv.log", + "sanitizedSourcePath": "SYNTHETIC://dp-root/Logs/SMSDPProv.log", + "pathFingerprint": "synthetic:healthy-provider", + "rotation": {"kind": "current", "lineageId": "healthy-provider", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 647, + "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..921c7c2cb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/expected.json new file mode 100644 index 000000000..8cb3d4c58 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/expected.json @@ -0,0 +1,131 @@ +{ + "artifactRequests": [ + { + "logicalId": "distmgr", + "reason": "Collect the complete distmgr.log file.", + "role": "siteServer" + }, + { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + }, + { + "logicalId": "smsDpProv", + "reason": "Collect the complete SMSDPProv.log file.", + "role": "distributionPoint" + } + ], + "coverageGaps": [ + { + "artifactIds": [ + "dp-incomplete-03-provider-absent" + ], + "producerRole": "distributionPoint", + "reason": "Distribution Point source coverage is absent; recollect the declared source without changing its state.", + "sourceId": "server-dp-distribution", + "state": "absent", + "workflowSubjectRole": null + }, + { + "artifactIds": [ + "dp-incomplete-02-pkgxfer-denied" + ], + "producerRole": "siteServer", + "reason": "Distribution Point source coverage is accessDenied; recollect the declared source without changing its state.", + "sourceId": "server-dp-distribution", + "state": "accessDenied", + "workflowSubjectRole": "distributionPoint" + } + ], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "insufficientEvidence", + "confidence": "low", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-incomplete-01-distmgr", + "entryId": "dp-incomplete-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-incomplete-01-distmgr", + "entryId": "dp-incomplete-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "key": { + "contentId": "content-eta", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00007", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "distribute", + "nextArtifact": { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + }, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-incomplete-01-distmgr", + "entryId": "dp-incomplete-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:08:00.000", + "utcMillis": 1785413280000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-incomplete-01-distmgr", + "entryId": "dp-incomplete-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:08:01.000", + "utcMillis": 1785413281000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Warning", + "state": "incomplete", + "stopPhase": "transfer", + "terminalEvidence": [], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00007:content=content-eta:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/manifest.json new file mode 100644 index 000000000..3912bb4a8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/manifest.json @@ -0,0 +1,61 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-incomplete-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:incomplete-distmgr", + "rotation": {"kind": "current", "lineageId": "incomplete-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 638, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-incomplete-02-pkgxfer-denied", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:incomplete-pkgxfer", + "rotation": {"kind": "current", "lineageId": "incomplete-pkgxfer"}, + "captureState": "accessDenied", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z" + }, + { + "artifactId": "dp-incomplete-03-provider-absent", + "sourceId": "server-dp-distribution", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "SMSDPProv.log", + "sanitizedSourcePath": "SYNTHETIC://dp-root/Logs/SMSDPProv.log", + "pathFingerprint": "synthetic:incomplete-provider", + "rotation": {"kind": "current", "lineageId": "incomplete-provider"}, + "captureState": "absent", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/evidence/server-dp-distribution/dp/current/SMSDPProv.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/evidence/server-dp-distribution/dp/current/SMSDPProv.log new file mode 100644 index 000000000..520e8a220 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/evidence/server-dp-distribution/dp/current/SMSDPProv.log @@ -0,0 +1 @@ +SYNTHETIC FIXTURE MALFORMED CCM RECORD WITHOUT ATTRIBUTES diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/expected.json new file mode 100644 index 000000000..712e757ff --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/expected.json @@ -0,0 +1,30 @@ +{ + "artifactRequests": [ + { + "logicalId": "smsDpProv", + "reason": "Collect the complete SMSDPProv.log file.", + "role": "distributionPoint" + } + ], + "coverageGaps": [ + { + "artifactIds": [ + "dp-malformed-01-provider" + ], + "producerRole": "distributionPoint", + "reason": "Distribution Point source coverage is parseFailed; recollect the declared source without changing its state.", + "sourceId": "server-dp-distribution", + "state": "parseFailed", + "workflowSubjectRole": null + } + ], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/manifest.json new file mode 100644 index 000000000..3498917cd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/manifest.json @@ -0,0 +1,29 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint"]}, + "artifacts": [ + { + "artifactId": "dp-malformed-01-provider", + "sourceId": "server-dp-distribution", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "SMSDPProv.log", + "sanitizedSourcePath": "SYNTHETIC://dp-root/Logs/SMSDPProv.log", + "pathFingerprint": "synthetic:malformed-provider", + "rotation": {"kind": "current", "lineageId": "malformed-provider", "fragmentComplete": true}, + "captureState": "parseFailed", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 58, + "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..a01c29afd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1 @@ +SYNTHETIC FIXTURE CURRENT FRAGMENT ONLY diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/expected.json new file mode 100644 index 000000000..018925d55 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/expected.json @@ -0,0 +1,36 @@ +{ + "artifactRequests": [ + { + "logicalId": "distmgr", + "reason": "Collect the complete distmgr.log file.", + "role": "siteServer" + }, + { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + } + ], + "coverageGaps": [ + { + "artifactIds": [ + "dp-rotation-01-current-fragment", + "dp-rotation-02-lo-fragment" + ], + "producerRole": "siteServer", + "reason": "Captured Distribution Point evidence is incomplete or outside the supported intake profile.", + "sourceId": "server-dp-distribution", + "state": "captured", + "workflowSubjectRole": "distributionPoint" + } + ], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/manifest.json new file mode 100644 index 000000000..fbd6d0d74 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/manifest.json @@ -0,0 +1,49 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-rotation-01-current-fragment", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:rotation-distmgr", + "rotation": {"kind": "current", "lineageId": "rotation-distmgr", "fragmentComplete": false}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 88, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-rotation-02-lo-fragment", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.lo_", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.lo_", + "pathFingerprint": "synthetic:rotation-distmgr", + "rotation": {"kind": "lo_", "lineageId": "rotation-distmgr", "fragmentComplete": false}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 84, + "relativePath": "evidence/server-dp-distribution/site/lo_/distmgr.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/dp/current/SMSDPProv.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/dp/current/SMSDPProv.log new file mode 100644 index 000000000..dcf7827fe --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/dp/current/SMSDPProv.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/site/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/site/current/PkgXferMgr.log new file mode 100644 index 000000000..6f3391cdf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/site/current/PkgXferMgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..f77c244ad --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-serve/dp/current/SMSdpmon.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-serve/dp/current/SMSdpmon.log new file mode 100644 index 000000000..58fe78d34 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-serve/dp/current/SMSdpmon.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/expected.json new file mode 100644 index 000000000..701ffb209 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/expected.json @@ -0,0 +1,186 @@ +{ + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "success", + "confidence": "high", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-serve-01-distmgr", + "entryId": "dp-serve-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-serve-01-distmgr", + "entryId": "dp-serve-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-serve-02-pkgxfer", + "entryId": "dp-serve-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-serve-03-provider", + "entryId": "dp-serve-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-serve-03-provider", + "entryId": "dp-serve-03-provider:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-serve-04-status", + "entryId": "dp-serve-04-status:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "contentId": "content-zeta", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00006", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "serveOrReport", + "nextArtifact": null, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-serve-01-distmgr", + "entryId": "dp-serve-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:06:00.000", + "utcMillis": 1785413160000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-serve-01-distmgr", + "entryId": "dp-serve-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:06:01.000", + "utcMillis": 1785413161000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-serve-02-pkgxfer", + "entryId": "dp-serve-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:06:02.000", + "utcMillis": 1785413162000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-serve-03-provider", + "entryId": "dp-serve-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "validate", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:06:03.000", + "utcMillis": 1785413163000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-serve-03-provider", + "entryId": "dp-serve-03-provider:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "makeAvailable", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:06:04.000", + "utcMillis": 1785413164000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-serve-04-status", + "entryId": "dp-serve-04-status:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "serveOrReport", + "sourceId": "server-dp-serve", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:06:05.000", + "utcMillis": 1785413165000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Success", + "state": "succeeded", + "stopPhase": null, + "terminalEvidence": [], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00006:content=content-zeta:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/manifest.json new file mode 100644 index 000000000..6abd64e68 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/manifest.json @@ -0,0 +1,89 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-serve-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:serve-distmgr", + "rotation": {"kind": "current", "lineageId": "serve-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 640, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-serve-02-pkgxfer", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:serve-pkgxfer", + "rotation": {"kind": "current", "lineageId": "serve-pkgxfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 322, + "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" + }, + { + "artifactId": "dp-serve-03-provider", + "sourceId": "server-dp-distribution", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "SMSDPProv.log", + "sanitizedSourcePath": "SYNTHETIC://dp-root/Logs/SMSDPProv.log", + "pathFingerprint": "synthetic:serve-provider", + "rotation": {"kind": "current", "lineageId": "serve-provider", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 645, + "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" + }, + { + "artifactId": "dp-serve-04-status", + "sourceId": "server-dp-serve", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "SMSdpmon.log", + "sanitizedSourcePath": "SYNTHETIC://dp-root/Logs/SMSdpmon.log", + "pathFingerprint": "synthetic:serve-status", + "rotation": {"kind": "current", "lineageId": "serve-status", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 322, + "relativePath": "evidence/server-dp-serve/dp/current/SMSdpmon.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/evidence/server-dp-distribution/site/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/evidence/server-dp-distribution/site/current/PkgXferMgr.log new file mode 100644 index 000000000..24f94326d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/evidence/server-dp-distribution/site/current/PkgXferMgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..e963a308e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/expected.json new file mode 100644 index 000000000..621d73908 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/expected.json @@ -0,0 +1,124 @@ +{ + "artifactRequests": [ + { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + } + ], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "blockedOrDeferred", + "confidence": "medium", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-transfer-deferred-01-distmgr", + "entryId": "dp-transfer-deferred-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-transfer-deferred-01-distmgr", + "entryId": "dp-transfer-deferred-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-transfer-deferred-02-pkgxfer", + "entryId": "dp-transfer-deferred-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "contentId": "content-iota", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00009", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "distribute", + "nextArtifact": { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + }, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-transfer-deferred-01-distmgr", + "entryId": "dp-transfer-deferred-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:00.000", + "utcMillis": 1785412980000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-transfer-deferred-01-distmgr", + "entryId": "dp-transfer-deferred-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:01.000", + "utcMillis": 1785412981000 + } + }, + { + "disposition": "deferred", + "evidence": { + "artifactId": "dp-transfer-deferred-02-pkgxfer", + "entryId": "dp-transfer-deferred-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:02.000", + "utcMillis": 1785412982000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Warning", + "state": "deferred", + "stopPhase": "transfer", + "terminalEvidence": [], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00009:content=content-iota:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/manifest.json new file mode 100644 index 000000000..8617da313 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/manifest.json @@ -0,0 +1,49 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-transfer-deferred-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:transfer-deferred-distmgr", + "rotation": {"kind": "current", "lineageId": "transfer-deferred-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 640, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-transfer-deferred-02-pkgxfer", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:transfer-deferred-pkgxfer", + "rotation": {"kind": "current", "lineageId": "transfer-deferred-pkgxfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 321, + "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/evidence/server-dp-distribution/site/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/evidence/server-dp-distribution/site/current/PkgXferMgr.log new file mode 100644 index 000000000..e69bb1522 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/evidence/server-dp-distribution/site/current/PkgXferMgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..df31d92dd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/expected.json new file mode 100644 index 000000000..1abbc7211 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/expected.json @@ -0,0 +1,121 @@ +{ + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "confirmedFailure", + "confidence": "high", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-transfer-failure-01-distmgr", + "entryId": "dp-transfer-failure-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-transfer-failure-01-distmgr", + "entryId": "dp-transfer-failure-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-transfer-failure-02-pkgxfer", + "entryId": "dp-transfer-failure-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "contentId": "content-eta", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00007", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "distribute", + "nextArtifact": null, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-transfer-failure-01-distmgr", + "entryId": "dp-transfer-failure-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:00.000", + "utcMillis": 1785412980000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-transfer-failure-01-distmgr", + "entryId": "dp-transfer-failure-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:01.000", + "utcMillis": 1785412981000 + } + }, + { + "disposition": "failed", + "evidence": { + "artifactId": "dp-transfer-failure-02-pkgxfer", + "entryId": "dp-transfer-failure-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:02.000", + "utcMillis": 1785412982000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Error", + "state": "failed", + "stopPhase": "transfer", + "terminalEvidence": [ + { + "artifactId": "dp-transfer-failure-02-pkgxfer", + "entryId": "dp-transfer-failure-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00007:content=content-eta:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/manifest.json new file mode 100644 index 000000000..49991a519 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/manifest.json @@ -0,0 +1,49 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-transfer-failure-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:transfer-failure-distmgr", + "rotation": {"kind": "current", "lineageId": "transfer-failure-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 638, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-transfer-failure-02-pkgxfer", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:transfer-failure-pkgxfer", + "rotation": {"kind": "current", "lineageId": "transfer-failure-pkgxfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 317, + "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/evidence/server-dp-distribution/site/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/evidence/server-dp-distribution/site/current/PkgXferMgr.log new file mode 100644 index 000000000..8b99c906d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/evidence/server-dp-distribution/site/current/PkgXferMgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..2d3ee92ad --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/expected.json new file mode 100644 index 000000000..a1bb6dc7d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/expected.json @@ -0,0 +1,124 @@ +{ + "artifactRequests": [ + { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + } + ], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "blockedOrDeferred", + "confidence": "medium", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-transfer-retry-01-distmgr", + "entryId": "dp-transfer-retry-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-transfer-retry-01-distmgr", + "entryId": "dp-transfer-retry-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-transfer-retry-02-pkgxfer", + "entryId": "dp-transfer-retry-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "contentId": "content-gamma", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00003", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "distribute", + "nextArtifact": { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + }, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-transfer-retry-01-distmgr", + "entryId": "dp-transfer-retry-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:00.000", + "utcMillis": 1785412980000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-transfer-retry-01-distmgr", + "entryId": "dp-transfer-retry-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:01.000", + "utcMillis": 1785412981000 + } + }, + { + "disposition": "retrying", + "evidence": { + "artifactId": "dp-transfer-retry-02-pkgxfer", + "entryId": "dp-transfer-retry-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:03:02.000", + "utcMillis": 1785412982000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Warning", + "state": "retrying", + "stopPhase": "transfer", + "terminalEvidence": [], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00003:content=content-gamma:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/manifest.json new file mode 100644 index 000000000..0ea3ae7e8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/manifest.json @@ -0,0 +1,49 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-transfer-retry-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:retry-distmgr", + "rotation": {"kind": "current", "lineageId": "retry-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 642, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-transfer-retry-02-pkgxfer", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:retry-pkgxfer", + "rotation": {"kind": "current", "lineageId": "retry-pkgxfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 322, + "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/dp/current/SMSDPProv.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/dp/current/SMSDPProv.log new file mode 100644 index 000000000..7e864a270 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/dp/current/SMSDPProv.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/site/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/site/current/PkgXferMgr.log new file mode 100644 index 000000000..bb9eaea71 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/site/current/PkgXferMgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/site/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/site/current/distmgr.log new file mode 100644 index 000000000..da9447a81 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/site/current/distmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/expected.json new file mode 100644 index 000000000..642e270ec --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/expected.json @@ -0,0 +1,145 @@ +{ + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, + "transactions": [ + { + "classification": "confirmedFailure", + "confidence": "high", + "contentVersionMismatch": false, + "evidence": [ + { + "artifactId": "dp-validation-failure-01-distmgr", + "entryId": "dp-validation-failure-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-validation-failure-01-distmgr", + "entryId": "dp-validation-failure-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "dp-validation-failure-02-pkgxfer", + "entryId": "dp-validation-failure-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "dp-validation-failure-03-provider", + "entryId": "dp-validation-failure-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "key": { + "contentId": "content-delta", + "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00004", + "siteCode": "LAB", + "topologySiteHandle": "synthetic:site:lab" + }, + "lastProvenPhase": "transfer", + "nextArtifact": null, + "observations": [ + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-validation-failure-01-distmgr", + "entryId": "dp-validation-failure-01-distmgr:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "receiveContent", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:04:00.000", + "utcMillis": 1785413040000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-validation-failure-01-distmgr", + "entryId": "dp-validation-failure-01-distmgr:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + "phase": "distribute", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:04:01.000", + "utcMillis": 1785413041000 + } + }, + { + "disposition": "succeeded", + "evidence": { + "artifactId": "dp-validation-failure-02-pkgxfer", + "entryId": "dp-validation-failure-02-pkgxfer:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "transfer", + "sourceId": "server-dp-distribution", + "terminal": false, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:04:02.000", + "utcMillis": 1785413042000 + } + }, + { + "disposition": "failed", + "evidence": { + "artifactId": "dp-validation-failure-03-provider", + "entryId": "dp-validation-failure-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + "phase": "validate", + "sourceId": "server-dp-distribution", + "terminal": true, + "timestamp": { + "offsetMinutes": 0, + "orderingState": "normalizedUtc", + "originalDisplay": "07-30-2026 12:04:03.000", + "utcMillis": 1785413043000 + } + } + ], + "recovered": false, + "scope": "distributionPointContent", + "severity": "Error", + "state": "failed", + "stopPhase": "validate", + "terminalEvidence": [ + { + "artifactId": "dp-validation-failure-03-provider", + "entryId": "dp-validation-failure-03-provider:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "transactionId": "dp:topology-site=synthetic:site:lab:site=LAB:package=LAB00004:content=content-delta:content-version=1:dp=synthetic:subject:dp-01:profile=dp-server-5.00.test-v1:profile-version=1" + } + ], + "workflow": "distributionPointContent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/manifest.json new file mode 100644 index 000000000..29f9e3525 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/manifest.json @@ -0,0 +1,69 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-validation-failure-01-distmgr", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "distmgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", + "pathFingerprint": "synthetic:validation-distmgr", + "rotation": {"kind": "current", "lineageId": "validation-distmgr", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 642, + "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" + }, + { + "artifactId": "dp-validation-failure-02-pkgxfer", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "PkgXferMgr.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/PkgXferMgr.log", + "pathFingerprint": "synthetic:validation-pkgxfer", + "rotation": {"kind": "current", "lineageId": "validation-pkgxfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 323, + "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" + }, + { + "artifactId": "dp-validation-failure-03-provider", + "sourceId": "server-dp-distribution", + "producerRole": "distributionPoint", + "producerHostHandle": "safe:dp:lab-dp-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "SMSDPProv.log", + "sanitizedSourcePath": "SYNTHETIC://dp-root/Logs/SMSDPProv.log", + "pathFingerprint": "synthetic:validation-provider", + "rotation": {"kind": "current", "lineageId": "validation-provider", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 317, + "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/README.md new file mode 100644 index 000000000..dab7181bb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/README.md @@ -0,0 +1,23 @@ +# Synthetic SCCM hierarchy and replication fixtures + +These fixtures are invented test data. They contain no customer, production, +or lab capture. `manifest.json` records additive SCCM artifact coverage and +physical provenance; `expected.json` records the proposed #331 evidence +contract while production reducers remain dependency-blocked. + +Only these raw CCM files are used as evidence: +`replmgr.log`, `sender.log` and its rotated `sender.lo_` form, `despool.log`, +and `rcmctrl.log`. Exact semantic records include the `SYNTHETIC FIXTURE` +marker and synthetic message/link/site/profile fields. The generic-message +negative contains the marker and a site-code-looking token without the exact +hierarchy grammar, so it cannot create a candidate. Partial rotation/cap +fixtures retain the marker but intentionally do not form a logical CCM record. +The healthy-link adversarial variants also freeze timestamp ordering: equal UTC +instants are usable only for forward physical lines in the same artifact, not +as ordering evidence between artifacts. Candidate facts retain the complete +shared timestamp shape and replace host/path inputs with versioned, +domain-separated SHA-256 provenance tokens before serialization. + +The corpus must remain deterministic, safe to publish, and role/topology aware. +Do not replace safe handles with hostnames, add database/network collection, or +interpret missing sources as role absence or failure. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/evidence/server-hierarchy-transfer/origin/current/sender.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/evidence/server-hierarchy-transfer/origin/current/sender.log new file mode 100644 index 000000000..ae17169ec --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/evidence/server-hierarchy-transfer/origin/current/sender.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/expected.json new file mode 100644 index 000000000..cb1641c1f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/expected.json @@ -0,0 +1,24 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "absent-remote-source", + "stateChain": ["initiate","queueOrSerialize","send","receive","process","acknowledge","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"crossSideCorrelationPerformed":false,"nativeCollectionPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"hierarchy-server-5.00.test-v1","validatedRole":"siteServer"}, + "coverage": [{"artifactId":"absent-01-sender","state":"captured"},{"artifactId":"absent-02-despool","state":"absent"}], + "transactions": [{ + "transactionId":"hierarchy:msg-absent-01:LAB:CHD:link-lab-chd", + "key":{"messageId":"msg-absent-01","linkId":"link-lab-chd","originSiteCode":"LAB","targetSiteCode":"CHD","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","terminalEvidence":false, + "state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low", + "coverageGapArtifactIds":["absent-02-despool"], + "observations":[{"observationId":"absent-01-send","phase":"send","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"absent-01-sender","startLine":1,"endLine":1}]}] + }], + "sourceLocalObservations": [], + "artifactRequests": [ + {"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"}, + {"sourceId":"server-hierarchy-transfer","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["despool.log"],"reasonCode":"missingTargetReceiveProcessApply"} + ], + "crossSideCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/manifest.json new file mode 100644 index 000000000..f625b8b41 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "absent-remote-source", + "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, + "topology": {"originSiteCode":"LAB","targetSiteCode":"CHD","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:lab-chd-01","rolesObserved":["siteServer"]}, + "artifacts": [ + {"artifactId":"absent-01-sender","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"sender.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/sender.log","pathFingerprint":"synthetic:absent-sender","rotation":{"kind":"current","lineageId":"absent-sender","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":322,"relativePath":"evidence/server-hierarchy-transfer/origin/current/sender.log"}, + {"artifactId":"absent-02-despool","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-chd-01","direction":"target","originalBasename":"despool.log","sanitizedSourcePath":"SYNTHETIC://configured-root/CHD/Logs/despool.log","pathFingerprint":"synthetic:absent-despool","rotation":{"kind":"current","lineageId":"absent-despool"},"captureState":"absent","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:01Z"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/evidence/server-hierarchy-control/origin/current/replmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/evidence/server-hierarchy-control/origin/current/replmgr.log new file mode 100644 index 000000000..295442b9a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/evidence/server-hierarchy-control/origin/current/replmgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/expected.json new file mode 100644 index 000000000..c0fac1777 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/expected.json @@ -0,0 +1,24 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "backlog-retry", + "stateChain": ["initiate","queueOrSerialize","send","receive","process","acknowledge","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"crossSideCorrelationPerformed":false,"nativeCollectionPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"hierarchy-server-5.00.test-v1","validatedRole":"siteServer"}, + "coverage": [{"artifactId":"backlog-01-replmgr","state":"captured"}], + "transactions": [{ + "transactionId":"hierarchy:msg-backlog-01:LAB:CHD:link-lab-chd", + "key":{"messageId":"msg-backlog-01","linkId":"link-lab-chd","originSiteCode":"LAB","targetSiteCode":"CHD","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","terminalEvidence":false, + "state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low", + "coverageGapArtifactIds":[], + "observations":[{"observationId":"backlog-01-queue","phase":"queueOrSerialize","disposition":"retrying","terminal":false,"evidence":[{"artifactId":"backlog-01-replmgr","startLine":1,"endLine":1}]}] + }], + "sourceLocalObservations": [], + "artifactRequests": [ + {"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"}, + {"sourceId":"server-hierarchy-transfer","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["despool.log"],"reasonCode":"missingTargetReceiveProcessApply"} + ], + "crossSideCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/manifest.json new file mode 100644 index 000000000..447cf6f27 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "backlog-retry", + "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, + "topology": {"originSiteCode":"LAB","targetSiteCode":"CHD","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:lab-chd-01","rolesObserved":["siteServer"]}, + "artifacts": [ + {"artifactId":"backlog-01-replmgr","sourceId":"server-hierarchy-control","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"replmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/replmgr.log","pathFingerprint":"synthetic:backlog-replmgr","rotation":{"kind":"current","lineageId":"backlog-replmgr","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":348,"relativePath":"evidence/server-hierarchy-control/origin/current/replmgr.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/evidence/server-hierarchy-transfer/origin/current/sender.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/evidence/server-hierarchy-transfer/origin/current/sender.log new file mode 100644 index 000000000..ee98a8514 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/evidence/server-hierarchy-transfer/origin/current/sender.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/evidence/server-hierarchy-transfer/target/current/despool.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/evidence/server-hierarchy-transfer/target/current/despool.log new file mode 100644 index 000000000..f942b7acc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/evidence/server-hierarchy-transfer/target/current/despool.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/expected.json new file mode 100644 index 000000000..ca97399d8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/expected.json @@ -0,0 +1,26 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "clock-offset-unknown", + "stateChain": ["initiate","queueOrSerialize","send","receive","process","acknowledge","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"crossSideCorrelationPerformed":false,"nativeCollectionPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"hierarchy-server-5.00.test-v1","validatedRole":"siteServer"}, + "coverage": [{"artifactId":"clock-01-sender","state":"captured"},{"artifactId":"clock-02-despool","state":"captured"}], + "transactions": [{ + "transactionId":"hierarchy:msg-clock-01:LAB:CHD:link-lab-chd", + "key":{"messageId":"msg-clock-01","linkId":"link-lab-chd","originSiteCode":"LAB","targetSiteCode":"CHD","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"unusableInvalidOffset","terminalEvidence":true, + "state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low", + "coverageGapArtifactIds":[], + "observations":[ + {"observationId":"clock-01-send","phase":"send","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"clock-01-sender","startLine":1,"endLine":1}]}, + {"observationId":"clock-02-process","phase":"process","disposition":"failed","terminal":true,"evidence":[{"artifactId":"clock-02-despool","startLine":1,"endLine":1}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [ + {"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"} + ], + "crossSideCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/manifest.json new file mode 100644 index 000000000..09529061e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "clock-offset-unknown", + "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, + "topology": {"originSiteCode":"LAB","targetSiteCode":"CHD","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:lab-chd-01","rolesObserved":["siteServer"]}, + "artifacts": [ + {"artifactId":"clock-01-sender","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"sender.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/sender.log","pathFingerprint":"synthetic:clock-sender","rotation":{"kind":"current","lineageId":"clock-sender","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":323,"relativePath":"evidence/server-hierarchy-transfer/origin/current/sender.log"}, + {"artifactId":"clock-02-despool","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-chd-01","direction":"target","originalBasename":"despool.log","sanitizedSourcePath":"SYNTHETIC://configured-root/CHD/Logs/despool.log","pathFingerprint":"synthetic:clock-despool","rotation":{"kind":"current","lineageId":"clock-despool","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:01Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":326,"relativePath":"evidence/server-hierarchy-transfer/target/current/despool.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/evidence/server-hierarchy-transfer/origin/current/sender.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/evidence/server-hierarchy-transfer/origin/current/sender.log new file mode 100644 index 000000000..419c840ab --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/evidence/server-hierarchy-transfer/origin/current/sender.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/expected.json new file mode 100644 index 000000000..4d198160a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/expected.json @@ -0,0 +1,39 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "generic-site-token", + "stateChain": [ + "initiate", + "queueOrSerialize", + "send", + "receive", + "process", + "acknowledge", + "healthyOrTerminal" + ], + "analysisContract": { + "independentReducer": true, + "crossSideCorrelationPerformed": false, + "nativeCollectionPerformed": false + }, + "extractionProfile": { + "selectionState": "selectedSynthetic", + "profileId": "hierarchy-server-5.00.test-v1", + "validatedRole": "siteServer" + }, + "coverage": [ + { + "artifactId": "generic-01-sender", + "state": "captured" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "artifactRequests": [], + "crossSideCausalClaims": [], + "correlationHandoff": { + "issue": "#333", + "performed": false, + "timeOnlyEligible": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/manifest.json new file mode 100644 index 000000000..e3b3c2d3f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/manifest.json @@ -0,0 +1,47 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "generic-site-token", + "bundle": { + "bundleRole": "server", + "workflow": "hierarchyAndReplication", + "capturedUtc": "2026-07-30T20:00:00Z" + }, + "topology": { + "originSiteCode": "LAB", + "targetSiteCode": "CHD", + "originHostHandle": "safe:server:lab-pri-01", + "targetHostHandle": "safe:server:lab-chd-01", + "rolesObserved": [ + "siteServer" + ] + }, + "artifacts": [ + { + "artifactId": "generic-01-sender", + "sourceId": "server-hierarchy-transfer", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "direction": "origin", + "originalBasename": "sender.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/sender.log", + "pathFingerprint": "synthetic:generic-site-token-sender", + "rotation": { + "kind": "current", + "lineageId": "generic-site-token-sender", + "fragmentComplete": true + }, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T19:00:09Z", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "bytesCopied": 228, + "relativePath": "evidence/server-hierarchy-transfer/origin/current/sender.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/origin/current/replmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/origin/current/replmgr.log new file mode 100644 index 000000000..135233cfc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/origin/current/replmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/origin/equal-instant/replmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/origin/equal-instant/replmgr.log new file mode 100644 index 000000000..58ffa51cb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/origin/equal-instant/replmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/target/current/rcmctrl.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/target/current/rcmctrl.log new file mode 100644 index 000000000..821c60a4f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/target/current/rcmctrl.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/origin/current/sender.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/origin/current/sender.log new file mode 100644 index 000000000..0cd7f7d3d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/origin/current/sender.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/origin/lo_/sender.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/origin/lo_/sender.lo_ new file mode 100644 index 000000000..0cd7f7d3d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/origin/lo_/sender.lo_ @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/target/current/despool.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/target/current/despool.log new file mode 100644 index 000000000..629be6538 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/target/current/despool.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/target/equal-instant/despool.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/target/equal-instant/despool.log new file mode 100644 index 000000000..b2eee7cfc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/target/equal-instant/despool.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/expected.json new file mode 100644 index 000000000..fa3b9f9f8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/expected.json @@ -0,0 +1,29 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "healthy-link", + "stateChain": ["initiate","queueOrSerialize","send","receive","process","acknowledge","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"crossSideCorrelationPerformed":false,"nativeCollectionPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"hierarchy-server-5.00.test-v1","validatedRole":"siteServer"}, + "coverage": [{"artifactId":"healthy-01-replmgr","state":"captured"},{"artifactId":"healthy-02-sender","state":"captured"},{"artifactId":"healthy-03-despool","state":"captured"},{"artifactId":"healthy-04-rcmctrl","state":"captured"}], + "transactions": [{ + "transactionId":"hierarchy:msg-healthy-01:LAB:CHD:link-lab-chd", + "key":{"messageId":"msg-healthy-01","linkId":"link-lab-chd","originSiteCode":"LAB","targetSiteCode":"CHD","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","terminalEvidence":true, + "state":"succeeded","classification":"success","confidence":"high","confidenceCeiling":"high", + "coverageGapArtifactIds":[], + "observations":[ + {"observationId":"healthy-01-initiate","phase":"initiate","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"healthy-01-replmgr","startLine":1,"endLine":1}]}, + {"observationId":"healthy-02-queue","phase":"queueOrSerialize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"healthy-01-replmgr","startLine":2,"endLine":2}]}, + {"observationId":"healthy-03-send","phase":"send","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"healthy-02-sender","startLine":1,"endLine":1}]}, + {"observationId":"healthy-04-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"healthy-03-despool","startLine":1,"endLine":1}]}, + {"observationId":"healthy-05-process","phase":"process","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"healthy-03-despool","startLine":2,"endLine":2}]}, + {"observationId":"healthy-06-acknowledge","phase":"acknowledge","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"healthy-04-rcmctrl","startLine":1,"endLine":1}]}, + {"observationId":"healthy-07-terminal","phase":"healthyOrTerminal","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"healthy-04-rcmctrl","startLine":2,"endLine":2}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [], + "crossSideCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/manifest.json new file mode 100644 index 000000000..98b00d361 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/manifest.json @@ -0,0 +1,14 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "healthy-link", + "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, + "topology": {"originSiteCode":"LAB","targetSiteCode":"CHD","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:lab-chd-01","rolesObserved":["siteServer"]}, + "artifacts": [ + {"artifactId":"healthy-01-replmgr","sourceId":"server-hierarchy-control","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"replmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/replmgr.log","pathFingerprint":"synthetic:healthy-replmgr","rotation":{"kind":"current","lineageId":"healthy-replmgr","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":690,"relativePath":"evidence/server-hierarchy-control/origin/current/replmgr.log"}, + {"artifactId":"healthy-02-sender","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"sender.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/sender.log","pathFingerprint":"synthetic:healthy-sender","rotation":{"kind":"current","lineageId":"healthy-sender","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:01Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":323,"relativePath":"evidence/server-hierarchy-transfer/origin/current/sender.log"}, + {"artifactId":"healthy-03-despool","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-chd-01","direction":"target","originalBasename":"despool.log","sanitizedSourcePath":"SYNTHETIC://configured-root/CHD/Logs/despool.log","pathFingerprint":"synthetic:healthy-despool","rotation":{"kind":"current","lineageId":"healthy-despool","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:02Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":660,"relativePath":"evidence/server-hierarchy-transfer/target/current/despool.log"}, + {"artifactId":"healthy-04-rcmctrl","sourceId":"server-hierarchy-control","producerRole":"siteServer","producerHostHandle":"safe:server:lab-chd-01","direction":"target","originalBasename":"rcmctrl.log","sanitizedSourcePath":"SYNTHETIC://configured-root/CHD/Logs/rcmctrl.log","pathFingerprint":"synthetic:healthy-rcmctrl","rotation":{"kind":"current","lineageId":"healthy-rcmctrl","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:03Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":721,"relativePath":"evidence/server-hierarchy-control/target/current/rcmctrl.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/incomplete/evidence/server-hierarchy-control/origin/current/replmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/incomplete/evidence/server-hierarchy-control/origin/current/replmgr.log new file mode 100644 index 000000000..e9b60eff4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/incomplete/evidence/server-hierarchy-control/origin/current/replmgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/evidence/server-hierarchy-transfer/target/current/despool.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/evidence/server-hierarchy-transfer/target/current/despool.log new file mode 100644 index 000000000..afbf81e5a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/evidence/server-hierarchy-transfer/target/current/despool.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/expected.json new file mode 100644 index 000000000..e6f092fb4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/expected.json @@ -0,0 +1,25 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "receiver-processing-failure", + "stateChain": ["initiate","queueOrSerialize","send","receive","process","acknowledge","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"crossSideCorrelationPerformed":false,"nativeCollectionPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"hierarchy-server-5.00.test-v1","validatedRole":"siteServer"}, + "coverage": [{"artifactId":"receiver-01-sender","state":"captured"},{"artifactId":"receiver-02-despool","state":"captured"}], + "transactions": [{ + "transactionId":"hierarchy:msg-receiver-01:LAB:CHD:link-lab-chd", + "key":{"messageId":"msg-receiver-01","linkId":"link-lab-chd","originSiteCode":"LAB","targetSiteCode":"CHD","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","terminalEvidence":true, + "state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low", + "coverageGapArtifactIds":[], + "observations":[ + {"observationId":"receiver-01-send","phase":"send","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"receiver-01-sender","startLine":1,"endLine":1}]}, + {"observationId":"receiver-02-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"receiver-02-despool","startLine":1,"endLine":1}]}, + {"observationId":"receiver-03-process","phase":"process","disposition":"failed","terminal":true,"evidence":[{"artifactId":"receiver-02-despool","startLine":2,"endLine":2}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [{"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"}], + "crossSideCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/manifest.json new file mode 100644 index 000000000..3bb5c54d7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "receiver-processing-failure", + "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, + "topology": {"originSiteCode":"LAB","targetSiteCode":"CHD","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:lab-chd-01","rolesObserved":["siteServer"]}, + "artifacts": [ + {"artifactId":"receiver-01-sender","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"sender.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/sender.log","pathFingerprint":"synthetic:receiver-sender","rotation":{"kind":"current","lineageId":"receiver-sender","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":324,"relativePath":"evidence/server-hierarchy-transfer/origin/current/sender.log"}, + {"artifactId":"receiver-02-despool","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-chd-01","direction":"target","originalBasename":"despool.log","sanitizedSourcePath":"SYNTHETIC://configured-root/CHD/Logs/despool.log","pathFingerprint":"synthetic:receiver-despool","rotation":{"kind":"current","lineageId":"receiver-despool","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:01Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":658,"relativePath":"evidence/server-hierarchy-transfer/target/current/despool.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/evidence/server-hierarchy-transfer/origin/current/sender.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/evidence/server-hierarchy-transfer/origin/current/sender.log new file mode 100644 index 000000000..bae61233c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/evidence/server-hierarchy-transfer/origin/current/sender.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/evidence/server-hierarchy-transfer/target/current/despool.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/evidence/server-hierarchy-transfer/target/current/despool.log new file mode 100644 index 000000000..745168ee2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/evidence/server-hierarchy-transfer/target/current/despool.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/expected.json new file mode 100644 index 000000000..7b78d488d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/expected.json @@ -0,0 +1,27 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "recovery", + "stateChain": ["initiate","queueOrSerialize","send","receive","process","acknowledge","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"crossSideCorrelationPerformed":false,"nativeCollectionPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"hierarchy-server-5.00.test-v1","validatedRole":"siteServer"}, + "coverage": [{"artifactId":"recovery-01-sender","state":"captured"},{"artifactId":"recovery-02-despool","state":"captured"}], + "transactions": [{ + "transactionId":"hierarchy:msg-recovery-01:LAB:CHD:link-lab-chd", + "key":{"messageId":"msg-recovery-01","linkId":"link-lab-chd","originSiteCode":"LAB","targetSiteCode":"CHD","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","terminalEvidence":true, + "state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low", + "coverageGapArtifactIds":[], + "observations":[ + {"observationId":"recovery-01-retry","phase":"send","disposition":"retrying","terminal":false,"evidence":[{"artifactId":"recovery-01-sender","startLine":1,"endLine":1}]}, + {"observationId":"recovery-02-send","phase":"send","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"recovery-01-sender","startLine":2,"endLine":2}]}, + {"observationId":"recovery-03-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"recovery-02-despool","startLine":1,"endLine":1}]}, + {"observationId":"recovery-04-process","phase":"process","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"recovery-02-despool","startLine":2,"endLine":2}]}, + {"observationId":"recovery-05-terminal","phase":"healthyOrTerminal","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"recovery-02-despool","startLine":3,"endLine":3}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [{"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"}], + "crossSideCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/manifest.json new file mode 100644 index 000000000..850f730e5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "recovery", + "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, + "topology": {"originSiteCode":"LAB","targetSiteCode":"CHD","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:lab-chd-01","rolesObserved":["siteServer"]}, + "artifacts": [ + {"artifactId":"recovery-01-sender","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"sender.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/sender.log","pathFingerprint":"synthetic:recovery-sender","rotation":{"kind":"current","lineageId":"recovery-sender","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":647,"relativePath":"evidence/server-hierarchy-transfer/origin/current/sender.log"}, + {"artifactId":"recovery-02-despool","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-chd-01","direction":"target","originalBasename":"despool.log","sanitizedSourcePath":"SYNTHETIC://configured-root/CHD/Logs/despool.log","pathFingerprint":"synthetic:recovery-despool","rotation":{"kind":"current","lineageId":"recovery-despool","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:01Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1002,"relativePath":"evidence/server-hierarchy-transfer/target/current/despool.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/evidence/server-hierarchy-transfer/origin/current/sender.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/evidence/server-hierarchy-transfer/origin/current/sender.log new file mode 100644 index 000000000..2fd2337c1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/evidence/server-hierarchy-transfer/origin/current/sender.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/expected.json new file mode 100644 index 000000000..888cc67f4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/expected.json @@ -0,0 +1,14 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "rotation-boundary", + "stateChain": ["initiate","queueOrSerialize","send","receive","process","acknowledge","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"crossSideCorrelationPerformed":false,"nativeCollectionPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"hierarchy-server-5.00.test-v1","validatedRole":"siteServer"}, + "coverage": [{"artifactId":"rotation-01-current","state":"captured"},{"artifactId":"rotation-02-lo","state":"captured"}], + "transactions": [], + "sourceLocalObservations": [{"observationId":"rotation-01-split","classification":"rotationSplit","confidence":"low","correlationEligible":false,"artifactIds":["rotation-01-current","rotation-02-lo"],"evidence":[]}], + "artifactRequests": [{"sourceId":"server-hierarchy-transfer","producerRole":"siteServer","direction":"origin","targetSiteCode":"CHD","basenames":["sender.lo_","sender.log"],"reasonCode":"coverageRotationSplit"}], + "crossSideCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/manifest.json new file mode 100644 index 000000000..499ff265e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "rotation-boundary", + "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, + "topology": {"originSiteCode":"LAB","targetSiteCode":"CHD","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:lab-chd-01","rolesObserved":["siteServer"]}, + "artifacts": [ + {"artifactId":"rotation-01-current","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"sender.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/sender.log","pathFingerprint":"synthetic:rotation-current","rotation":{"kind":"current","lineageId":"rotation-sender","fragmentComplete":false},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":100,"relativePath":"evidence/server-hierarchy-transfer/origin/current/sender.log"}, + {"artifactId":"rotation-02-lo","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"sender.lo_","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/sender.lo_","pathFingerprint":"synthetic:rotation-lo","rotation":{"kind":"loUnderscore","lineageId":"rotation-sender","fragmentComplete":false},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:01Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":252,"relativePath":"evidence/server-hierarchy-transfer/origin/lo_/sender.lo_"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/evidence/server-hierarchy-transfer/origin/current/sender.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/evidence/server-hierarchy-transfer/origin/current/sender.log new file mode 100644 index 000000000..bcdd7eb0a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/evidence/server-hierarchy-transfer/origin/current/sender.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/expected.json new file mode 100644 index 000000000..27727275c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/expected.json @@ -0,0 +1,34 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "sender-failure", + "stateChain": ["initiate","queueOrSerialize","send","receive","process","acknowledge","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"crossSideCorrelationPerformed":false,"nativeCollectionPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"hierarchy-server-5.00.test-v1","validatedRole":"siteServer"}, + "coverage": [{"artifactId":"sender-failure-01-chd","state":"captured"}], + "transactions": [ + { + "transactionId":"hierarchy:msg-send-chd:LAB:CHD:link-lab-chd", + "key":{"messageId":"msg-send-chd","linkId":"link-lab-chd","originSiteCode":"LAB","targetSiteCode":"CHD","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","terminalEvidence":true, + "state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low","coverageGapArtifactIds":[], + "observations":[{"observationId":"sender-01-chd-failure","phase":"send","disposition":"failed","terminal":true,"evidence":[{"artifactId":"sender-failure-01-chd","startLine":1,"endLine":1}]}] + }, + { + "transactionId":"hierarchy:msg-send-sec:LAB:SEC:link-lab-sec", + "key":{"messageId":"msg-send-sec","linkId":"link-lab-sec","originSiteCode":"LAB","targetSiteCode":"SEC","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","terminalEvidence":true, + "state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low","coverageGapArtifactIds":[], + "observations":[{"observationId":"sender-02-sec-failure","phase":"send","disposition":"failed","terminal":true,"evidence":[{"artifactId":"sender-failure-01-chd","startLine":2,"endLine":2}]}] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [ + {"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"}, + {"sourceId":"server-hierarchy-transfer","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["despool.log"],"reasonCode":"missingTargetReceiveProcessApply"}, + {"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"SEC","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"}, + {"sourceId":"server-hierarchy-transfer","producerRole":"siteServer","direction":"target","targetSiteCode":"SEC","basenames":["despool.log"],"reasonCode":"missingTargetReceiveProcessApply"} + ], + "crossSideCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/manifest.json new file mode 100644 index 000000000..65cc6db0a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "sender-failure", + "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, + "topology": {"originSiteCode":"LAB","targetSiteCode":"CHD","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:lab-chd-01","additionalTargets":[{"siteCode":"SEC","hostHandle":"safe:server:lab-sec-01"}],"rolesObserved":["siteServer"]}, + "artifacts": [ + {"artifactId":"sender-failure-01-chd","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"sender.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/sender.log","pathFingerprint":"synthetic:sender-failure-current","rotation":{"kind":"current","lineageId":"sender-failure","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":634,"relativePath":"evidence/server-hierarchy-transfer/origin/current/sender.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/evidence/server-hierarchy-transfer/origin/current/sender.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/evidence/server-hierarchy-transfer/origin/current/sender.log new file mode 100644 index 000000000..746981b5d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/evidence/server-hierarchy-transfer/origin/current/sender.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/evidence/server-hierarchy-transfer/target/current/despool.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/evidence/server-hierarchy-transfer/target/current/despool.log new file mode 100644 index 000000000..69a33e17c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/evidence/server-hierarchy-transfer/target/current/despool.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/expected.json new file mode 100644 index 000000000..8feecd461 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/expected.json @@ -0,0 +1,39 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "hierarchyAndReplication", + "scenario": "topology-mismatch", + "stateChain": ["initiate","queueOrSerialize","send","receive","process","acknowledge","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"crossSideCorrelationPerformed":false,"nativeCollectionPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"hierarchy-server-5.00.test-v1","validatedRole":"siteServer"}, + "coverage": [{"artifactId":"mismatch-01-sender","state":"captured"},{"artifactId":"mismatch-02-despool","state":"captured"}], + "transactions": [ + { + "transactionId":"hierarchy:msg-mismatch-01:LAB:CHD:link-lab-chd", + "key":{"messageId":"msg-mismatch-01","linkId":"link-lab-chd","originSiteCode":"LAB","targetSiteCode":"CHD","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","terminalEvidence":false, + "state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low", + "coverageGapArtifactIds":[], + "observations":[ + {"observationId":"mismatch-01-origin","phase":"send","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"mismatch-01-sender","startLine":1,"endLine":1}]} + ] + }, + { + "transactionId":"hierarchy:msg-mismatch-01:LAB:SEC:link-lab-sec", + "key":{"messageId":"msg-mismatch-01","linkId":"link-lab-sec","originSiteCode":"LAB","targetSiteCode":"SEC","confidence":"exact","extractionProfileId":"hierarchy-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","terminalEvidence":false, + "state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low", + "coverageGapArtifactIds":[], + "observations":[ + {"observationId":"mismatch-02-target","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"mismatch-02-despool","startLine":1,"endLine":1}]} + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [ + {"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"}, + {"sourceId":"server-hierarchy-transfer","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["despool.log"],"reasonCode":"missingTargetReceiveProcessApply"}, + {"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"SEC","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"} + ], + "crossSideCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/manifest.json new file mode 100644 index 000000000..591787061 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "topology-mismatch", + "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, + "topology": {"originSiteCode":"LAB","targetSiteCode":"CHD","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:lab-chd-01","additionalTargets":[{"siteCode":"SEC","hostHandle":"safe:server:lab-sec-01"}],"rolesObserved":["siteServer"]}, + "artifacts": [ + {"artifactId":"mismatch-01-sender","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","direction":"origin","originalBasename":"sender.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/sender.log","pathFingerprint":"synthetic:mismatch-sender","rotation":{"kind":"current","lineageId":"mismatch-sender","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":324,"relativePath":"evidence/server-hierarchy-transfer/origin/current/sender.log"}, + {"artifactId":"mismatch-02-despool","sourceId":"server-hierarchy-transfer","producerRole":"siteServer","producerHostHandle":"safe:server:lab-sec-01","direction":"target","originalBasename":"despool.log","sanitizedSourcePath":"SYNTHETIC://configured-root/SEC/Logs/despool.log","pathFingerprint":"synthetic:mismatch-despool","rotation":{"kind":"current","lineageId":"mismatch-despool","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T19:00:01Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":331,"relativePath":"evidence/server-hierarchy-transfer/target/current/despool.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/absent-dp/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/absent-dp/expected.json new file mode 100644 index 000000000..708ee7be4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/absent-dp/expected.json @@ -0,0 +1,8 @@ +{ + "pre318ExpectedVersion": 1, + "coverage": [{ "producerRole": "siteServer", "workflowSubjectRole": "distributionPoint", "sourceId": "server-dp-distribution", "state": "absent", "gap": "candidate absent only" }], + "nonCapturedProvenance": { "encoding": "omitted", "collectionLimit": "omitted" }, + "rolesObserved": ["siteServer"], + "roleHealthFinding": "none", + "forbiddenConclusion": "distribution point broken or absent" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/absent-dp/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/absent-dp/manifest.json new file mode 100644 index 000000000..17e21458b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/absent-dp/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, + "artifacts": [ + { "artifactId": "dp-distribution-absent-candidate", "producerRole": "siteServer", "producerHostHandle": "synthetic:host:site-01", "workflowSubject": { "role": "distributionPoint", "basis": "incidentScopeOnly" }, "sourceId": "server-dp-distribution", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_DEFAULT_DP_CANDIDATE", "originalBasename": "distmgr.log", "configuredPathProvenance": { "state": "defaultCandidate", "pathFingerprint": "synthetic:path:dp-default" }, "rotation": { "kind": "current", "lineageId": "dp-distribution-default" }, "captureState": "absent", "collectedUtc": "2026-07-30T00:04:00Z", "relativePath": null, "bytesCopied": 0 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/access-denied-mp/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/access-denied-mp/expected.json new file mode 100644 index 000000000..660fd3fd9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/access-denied-mp/expected.json @@ -0,0 +1,7 @@ +{ + "pre318ExpectedVersion": 1, + "coverage": [{ "producerRole": "managementPoint", "sourceId": "server-mp-policy", "state": "accessDenied" }], + "nonCapturedProvenance": { "encoding": "omitted", "collectionLimit": "omitted" }, + "nextArtifactRequest": "read-only capture of server-mp-policy from the observed management point", + "terminalManagementPointDiagnosis": false +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/access-denied-mp/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/access-denied-mp/manifest.json new file mode 100644 index 000000000..cb93c009e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/access-denied-mp/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-MP01", "siteCode": "LAB", "rolesObserved": ["managementPoint"] }, + "artifacts": [ + { "artifactId": "mp-policy-access-denied", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_MP_ROOT", "originalBasename": "MP_GetPolicy.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:mp-default" }, "rotation": { "kind": "current", "lineageId": "mp-policy-access" }, "captureState": "accessDenied", "collectionDetail": "synthetic permission denial", "collectedUtc": "2026-07-30T00:05:00Z", "relativePath": null, "bytesCopied": 0 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/capped-sup/evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/instance-17eae15500d8968f/root-b11afca548220198/current/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/capped-sup/evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/instance-17eae15500d8968f/root-b11afca548220198/current/wsyncmgr.log new file mode 100644 index 000000000..c970ac89a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/capped-sup/evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/instance-17eae15500d8968f/root-b11afca548220198/current/wsyncmgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/evidence/sccm/server/management-point/server-mp-policy/root-b83f10d6/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/evidence/sccm/server/management-point/server-mp-policy/root-b83f10d6/current/MP_GetPolicy.log new file mode 100644 index 000000000..5b3822960 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/evidence/sccm/server/management-point/server-mp-policy/root-b83f10d6/current/MP_GetPolicy.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/expected.json new file mode 100644 index 000000000..6043945d3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/expected.json @@ -0,0 +1,17 @@ +{ + "pre318ExpectedVersion": 1, + "canonicalArtifactIds": ["mp-policy-root-a-current", "mp-policy-root-b-current"], + "coverage": [{ "producerRole": "managementPoint", "sourceId": "server-mp-policy", "state": "captured", "configuredRootInstances": 2 }], + "collisionAssertions": { + "sameBasename": "MP_GetPolicy.log", + "distinctPathFingerprints": true, + "distinctOpaqueRootSegments": true, + "notMerged": true, + "normalizedArtifactCount": 2, + "exactReferencesResolve": true + }, + "artifactProvenance": [ + { "artifactId": "mp-policy-root-a-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 173, "relativePath": "evidence/sccm/server/management-point/server-mp-policy/root-7d4a9c2e/current/MP_GetPolicy.log", "sha256": "021ea9b3a82c25f42095ee2fe460ed25718193b3798f624041a0621a62ed6cd0" }, + { "artifactId": "mp-policy-root-b-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 172, "relativePath": "evidence/sccm/server/management-point/server-mp-policy/root-b83f10d6/current/MP_GetPolicy.log", "sha256": "b95b8b26b4d87f9a6a9b708a8290a4ac9bb30b9c95849e20d6b434d14d91f909" } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/manifest.json new file mode 100644 index 000000000..32d856c01 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-MP01", "siteCode": "LAB", "rolesObserved": ["managementPoint"] }, + "artifacts": [ + { "artifactId": "mp-policy-root-a-current", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_CONFIGURED_MP_ROOT_A", "originalBasename": "MP_GetPolicy.log", "configuredPathProvenance": { "state": "configured", "pathClass": "nonDefault", "pathFingerprint": "synthetic:path:mp-root-a" }, "rotation": { "kind": "current", "lineageId": "mp-policy-root-a" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:10:00Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/root-7d4a9c2e/current/MP_GetPolicy.log", "bytesCopied": 173 }, + { "artifactId": "mp-policy-root-b-current", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_CONFIGURED_MP_ROOT_B", "originalBasename": "MP_GetPolicy.log", "configuredPathProvenance": { "state": "configured", "pathClass": "nonDefault", "pathFingerprint": "synthetic:path:mp-root-b" }, "rotation": { "kind": "current", "lineageId": "mp-policy-root-b" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:10:00Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/root-b83f10d6/current/MP_GetPolicy.log", "bytesCopied": 172 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..b3b144759 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-dp-distribution/subject-distribution-point/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-dp-distribution/subject-distribution-point/current/distmgr.log new file mode 100644 index 000000000..1c8c38711 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-dp-distribution/subject-distribution-point/current/distmgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log new file mode 100644 index 000000000..45495b854 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/current/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/current/wsyncmgr.log new file mode 100644 index 000000000..373880037 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/current/wsyncmgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/expected.json new file mode 100644 index 000000000..b8cc218dd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/expected.json @@ -0,0 +1,18 @@ +{ + "pre318ExpectedVersion": 1, + "privacy": "synthetic", + "canonicalArtifactIds": ["mp-policy-current", "dp-dist-current", "sitecomp-current", "sup-sync-current"], + "coverage": [ + { "producerRole": "managementPoint", "sourceId": "server-mp-policy", "state": "captured" }, + { "producerRole": "siteServer", "workflowSubjectRole": "distributionPoint", "sourceId": "server-dp-distribution", "state": "captured" }, + { "producerRole": "siteServer", "sourceId": "server-sitecomp", "state": "captured" }, + { "producerRole": "siteServer", "workflowSubjectRole": "softwareUpdatePoint", "sourceId": "server-sup-sync", "state": "captured" } + ], + "artifactProvenance": [ + { "artifactId": "mp-policy-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false }, + { "artifactId": "dp-dist-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false }, + { "artifactId": "sitecomp-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false }, + { "artifactId": "sup-sync-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false } + ], + "roleHealthFinding": "none" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/manifest.json new file mode 100644 index 000000000..515dd4a60 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/manifest.json @@ -0,0 +1,14 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer", "managementPoint", "distributionPoint", "softwareUpdatePoint"] }, + "artifacts": [ + { "artifactId": "sitecomp-current", "producerRole": "siteServer", "producerHostHandle": "synthetic:host:site-01", "sourceId": "server-sitecomp", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:00:00Z", "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 171 }, + { "artifactId": "mp-policy-current", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_MP_ROOT", "originalBasename": "MP_GetPolicy.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:mp-default" }, "rotation": { "kind": "current", "lineageId": "mp-policy-lab" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:00:01Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log", "bytesCopied": 184 }, + { "artifactId": "dp-dist-current", "producerRole": "siteServer", "producerHostHandle": "synthetic:host:site-01", "workflowSubject": { "role": "distributionPoint", "instanceHandle": "synthetic:subject:dp-01" }, "sourceId": "server-dp-distribution", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_SITE_DP_CONTROL_ROOT", "originalBasename": "distmgr.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-dp-control" }, "rotation": { "kind": "current", "lineageId": "dp-dist-lab" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:00:02Z", "relativePath": "evidence/sccm/server/site-server/server-dp-distribution/subject-distribution-point/current/distmgr.log", "bytesCopied": 174 }, + { "artifactId": "sup-sync-current", "producerRole": "siteServer", "producerHostHandle": "synthetic:host:site-01", "workflowSubject": { "role": "softwareUpdatePoint", "instanceHandle": "synthetic:subject:sup-01" }, "sourceId": "server-sup-sync", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_SITE_SUP_CONTROL_ROOT", "originalBasename": "wsyncmgr.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-sup-control" }, "rotation": { "kind": "current", "lineageId": "sup-sync-lab" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:00:03Z", "relativePath": "evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/current/wsyncmgr.log", "bytesCopied": 178 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..331a1f47b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/expected.json new file mode 100644 index 000000000..55bd2c0bb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/expected.json @@ -0,0 +1,9 @@ +{ + "pre318ExpectedVersion": 1, + "artifactId": "mp-policy-configured", + "configuredPathProvenance": { "state": "configured", "pathClass": "nonDefault", "pathFingerprint": "synthetic:path:mp-configured-a" }, + "defaultCandidateInterpretation": "candidateAbsentOnly", + "coverage": [{ "producerRole": "managementPoint", "sourceId": "server-mp-policy", "state": "captured" }], + "artifactProvenance": [{ "artifactId": "mp-policy-configured", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false }], + "roleInference": "managementPoint is observed from topology, not from default path" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/manifest.json new file mode 100644 index 000000000..3986a09b4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-MP01", "siteCode": "LAB", "rolesObserved": ["managementPoint"] }, + "artifacts": [ + { "artifactId": "mp-policy-configured", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_CONFIGURED_NONDEFAULT_ROOT", "originalBasename": "MP_GetPolicy.log", "configuredPathProvenance": { "state": "configured", "pathClass": "nonDefault", "pathFingerprint": "synthetic:path:mp-configured-a" }, "defaultCandidateState": "absentCandidateOnly", "rotation": { "kind": "current", "lineageId": "mp-policy-configured" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:01:00Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log", "bytesCopied": 183 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..6955ee76b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1,2 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/expected.json new file mode 100644 index 000000000..e4e75e818 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/expected.json @@ -0,0 +1,7 @@ +{ + "pre318ExpectedVersion": 1, + "evidence": [{ "artifactId": "mp-policy-multiline", "lineRange": { "start": 1, "end": 2 }, "logicalRecordCount": 1 }], + "coverage": [{ "producerRole": "managementPoint", "sourceId": "server-mp-policy", "state": "captured" }], + "artifactProvenance": [{ "artifactId": "mp-policy-multiline", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 207 }], + "partialPhysicalFragmentCreatesTerminalResult": false +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/manifest.json new file mode 100644 index 000000000..64b91d9bb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-MP01", "siteCode": "LAB", "rolesObserved": ["managementPoint"] }, + "artifacts": [ + { "artifactId": "mp-policy-multiline", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_MP_ROOT", "originalBasename": "MP_GetPolicy.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:mp-default" }, "rotation": { "kind": "current", "lineageId": "mp-policy-multiline" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:03:00Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log", "bytesCopied": 207 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..b8723e735 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/lo_/MP_GetPolicy.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/lo_/MP_GetPolicy.lo_ new file mode 100644 index 000000000..136b5ca76 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/lo_/MP_GetPolicy.lo_ @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/numbered-2/MP_GetPolicy.log.2 b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/numbered-2/MP_GetPolicy.log.2 new file mode 100644 index 000000000..38273c20e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/numbered-2/MP_GetPolicy.log.2 @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/timestamped-20260729-235700/MP_GetPolicy.log.20260729-235700 b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/timestamped-20260729-235700/MP_GetPolicy.log.20260729-235700 new file mode 100644 index 000000000..21d63ce2f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/timestamped-20260729-235700/MP_GetPolicy.log.20260729-235700 @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/expected.json new file mode 100644 index 000000000..5aec5351d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/expected.json @@ -0,0 +1,16 @@ +{ + "pre318ExpectedVersion": 1, + "lineageId": "mp-policy-rotation", + "canonicalRotationArtifactIds": ["mp-policy-ts-20260729-235700", "mp-policy-numbered-2", "mp-policy-lo", "mp-policy-current"], + "totalRotationSort": true, + "serializationOrderIsChronology": false, + "uniqueRelativePaths": true, + "collisionSafe": true, + "coverage": [{ "producerRole": "managementPoint", "sourceId": "server-mp-policy", "state": "captured" }], + "artifactProvenance": [ + { "artifactId": "mp-policy-ts-20260729-235700", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 182 }, + { "artifactId": "mp-policy-numbered-2", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 179 }, + { "artifactId": "mp-policy-lo", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 177 }, + { "artifactId": "mp-policy-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 178 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/manifest.json new file mode 100644 index 000000000..48051361c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/manifest.json @@ -0,0 +1,14 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-MP01", "siteCode": "LAB", "rolesObserved": ["managementPoint"] }, + "artifacts": [ + { "artifactId": "mp-policy-ts-20260729-235700", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_MP_ROOT", "originalBasename": "MP_GetPolicy.log.20260729-235700", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:mp-default" }, "rotation": { "kind": "timestamped", "value": "20260729-235700", "lineageId": "mp-policy-rotation" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:02:00Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/timestamped-20260729-235700/MP_GetPolicy.log.20260729-235700", "bytesCopied": 182 }, + { "artifactId": "mp-policy-current", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_MP_ROOT", "originalBasename": "MP_GetPolicy.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:mp-default" }, "rotation": { "kind": "current", "lineageId": "mp-policy-rotation" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:02:00Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log", "bytesCopied": 178 }, + { "artifactId": "mp-policy-lo", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_MP_ROOT", "originalBasename": "MP_GetPolicy.lo_", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:mp-default" }, "rotation": { "kind": "lo_", "lineageId": "mp-policy-rotation" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:02:00Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/lo_/MP_GetPolicy.lo_", "bytesCopied": 177 }, + { "artifactId": "mp-policy-numbered-2", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_MP_ROOT", "originalBasename": "MP_GetPolicy.log.2", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:mp-default" }, "rotation": { "kind": "numbered", "value": 2, "lineageId": "mp-policy-rotation" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:02:00Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/numbered-2/MP_GetPolicy.log.2", "bytesCopied": 179 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/skipped-iis/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/skipped-iis/expected.json new file mode 100644 index 000000000..f563b47bd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/skipped-iis/expected.json @@ -0,0 +1,7 @@ +{ + "pre318ExpectedVersion": 1, + "coverage": [{ "producerRole": "managementPoint", "sourceId": "server-mp-iis", "state": "skipped", "requiredness": "optionalSupplemental" }], + "nonCapturedProvenance": { "encoding": "omitted", "collectionLimit": "omitted" }, + "requiredSourceFailure": false, + "roleHealthFinding": "none" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/skipped-iis/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/skipped-iis/manifest.json new file mode 100644 index 000000000..74e1ebf47 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/skipped-iis/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-MP01", "siteCode": "LAB", "rolesObserved": ["managementPoint"] }, + "artifacts": [ + { "artifactId": "mp-iis-skipped", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-iis", "sourceKind": "iisW3c", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_IIS_EXPORT", "originalBasename": "u_ex_synthetic.log", "configuredPathProvenance": { "state": "notRequested", "pathFingerprint": "synthetic:path:iis-not-requested" }, "rotation": { "kind": "providerDefined", "lineageId": "mp-iis-supplement" }, "captureState": "skipped", "skipReason": "optional supplemental source not requested", "collectedUtc": "2026-07-30T00:07:00Z", "relativePath": null, "bytesCopied": 0 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/supplemental-wsus-skipped/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/supplemental-wsus-skipped/expected.json new file mode 100644 index 000000000..5b0ebf9e9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/supplemental-wsus-skipped/expected.json @@ -0,0 +1,9 @@ +{ + "pre318ExpectedVersion": 1, + "coverage": [{ "producerRole": "wsUs", "workflowSubjectRole": "softwareUpdatePoint", "sourceId": "server-sup-wsus", "state": "skipped" }], + "nonCapturedProvenance": { "encoding": "omitted", "collectionLimit": "omitted" }, + "requiredSourceFailure": false, + "terminalSoftwareUpdatePointHealth": false, + "roleHealthFinding": "none", + "privacy": "synthetic" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/supplemental-wsus-skipped/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/supplemental-wsus-skipped/manifest.json new file mode 100644 index 000000000..fe8f697a2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/supplemental-wsus-skipped/manifest.json @@ -0,0 +1,38 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": ["softwareUpdatePoint", "wsUs"] + }, + "artifacts": [ + { + "artifactId": "sup-wsus-health-skipped", + "producerRole": "wsUs", + "producerHostHandle": "synthetic:host:wsus-01", + "workflowSubject": { + "role": "softwareUpdatePoint", + "instanceHandle": "synthetic:subject:sup-01" + }, + "sourceId": "server-sup-wsus", + "sourceKind": "profileDefined", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_WSUS_HEALTH_EXPORT", + "originalBasename": "WsusHealth.json", + "configuredPathProvenance": { + "state": "notRequested", + "pathFingerprint": "synthetic:path:sup-wsus-health" + }, + "rotation": { "kind": "providerDefined", "lineageId": "sup-wsus-health" }, + "captureState": "skipped", + "skipReason": "optional supplemental source not requested", + "collectedUtc": "2026-07-30T00:08:00Z", + "relativePath": null, + "bytesCopied": 0 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..dcdd9d795 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log new file mode 100644 index 000000000..5503d0e3c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/site-server/server-status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/site-server/server-status/current/statmgr.log new file mode 100644 index 000000000..80c64bd9e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/site-server/server-status/current/statmgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/expected.json new file mode 100644 index 000000000..0efb1836c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/expected.json @@ -0,0 +1,19 @@ +{ + "pre318ExpectedVersion": 1, + "canonicalArtifactIds": ["a-mp-policy", "b-sitecomp", "z-site-status"], + "normalizedOutputByteIdenticalWhenReordered": true, + "artifactIdDerivationIgnoresDiscoveryOrder": true, + "artifactIdUniquenessScope": "manifest", + "crossBundleArtifactIdReuseAllowed": true, + "deterministicEvidenceIds": true, + "coverage": [ + { "producerRole": "managementPoint", "sourceId": "server-mp-policy", "state": "captured" }, + { "producerRole": "siteServer", "sourceId": "server-sitecomp", "state": "captured" }, + { "producerRole": "siteServer", "sourceId": "server-status", "state": "captured" } + ], + "artifactProvenance": [ + { "artifactId": "a-mp-policy", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false }, + { "artifactId": "b-sitecomp", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false }, + { "artifactId": "z-site-status", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/manifest.json new file mode 100644 index 000000000..28e9c338f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/manifest.json @@ -0,0 +1,14 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer", "managementPoint"] }, + "inputOrderIsDeliberatelyUnsorted": true, + "artifacts": [ + { "artifactId": "z-site-status", "producerRole": "siteServer", "producerHostHandle": "synthetic:host:site-01", "sourceId": "server-status", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_SITE_ROOT_B", "originalBasename": "statmgr.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:09:00Z", "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", "bytesCopied": 167 }, + { "artifactId": "a-mp-policy", "producerRole": "managementPoint", "producerHostHandle": "synthetic:host:mp-01", "sourceId": "server-mp-policy", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_MP_ROOT_A", "originalBasename": "MP_GetPolicy.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:a-mp" }, "rotation": { "kind": "current", "lineageId": "mp-policy-a" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:09:00Z", "relativePath": "evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log", "bytesCopied": 176 }, + { "artifactId": "b-sitecomp", "producerRole": "siteServer", "producerHostHandle": "synthetic:host:site-01", "sourceId": "server-sitecomp", "sourceKind": "ccmLog", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_SITE_ROOT_A", "originalBasename": "sitecomp.log", "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:a-site" }, "rotation": { "kind": "current", "lineageId": "sitecomp-a" }, "captureState": "captured", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, "collectedUtc": "2026-07-30T00:09:00Z", "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 180 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsupported-db-supplement/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsupported-db-supplement/expected.json new file mode 100644 index 000000000..f9f0d232e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsupported-db-supplement/expected.json @@ -0,0 +1,8 @@ +{ + "pre318ExpectedVersion": 1, + "retainedUnclassifiedArtifactIds": ["unknown-db-export"], + "coverage": [{ "producerRole": "unclassified", "sourceId": "unknown-db-supplement", "state": "unsupported" }], + "nonCapturedProvenance": { "encoding": "omitted", "collectionLimit": "omitted" }, + "eligibleForRoleReducer": false, + "databaseOrRoleFinding": "none" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsupported-db-supplement/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsupported-db-supplement/manifest.json new file mode 100644 index 000000000..0455b1202 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsupported-db-supplement/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, + "artifacts": [ + { "artifactId": "unknown-db-export", "producerRole": "unclassified", "producerHostHandle": null, "sourceId": "unknown-db-supplement", "sourceKind": "unknown", "sourceVersion": "5.00.TEST", "originalPath": "REDACTED_UNSUPPORTED_EXPORT", "originalBasename": "synthetic-db-export.txt", "configuredPathProvenance": { "state": "supplied", "pathFingerprint": "synthetic:path:unsupported-db" }, "rotation": { "kind": "none", "lineageId": "unknown-db-export" }, "captureState": "unsupported", "unsupportedReason": "no approved server source contract", "collectedUtc": "2026-07-30T00:08:00Z", "relativePath": null, "bytesCopied": 0 } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/evidence/server-mp-auth/current/MP_GetAuth.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/evidence/server-mp-auth/current/MP_GetAuth.log new file mode 100644 index 000000000..10df0ce64 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/evidence/server-mp-auth/current/MP_GetAuth.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/expected.json new file mode 100644 index 000000000..74819130f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/expected.json @@ -0,0 +1,51 @@ +{ + "contractState": "proposedPending318And335", + "workflow": "managementPoint", + "scenario": "auth-failure", + "stateChain": ["receiveRequest","authenticate","registerOrIdentify","resolveLocationOrPolicy","respond","recordOutcome"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"consumesSiteCoreOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"mp-server-5.00.test-v1","sourceVersionPrefix":"5.00.TEST","validatedArtifactFamilies":["server-mp-auth","server-mp-policy"],"validatedRole":"managementPoint"}, + "topology": {"role":"managementPoint","captureHostHandle":"safe:mp:lab-mp-01","siteCode":"LAB"}, + "roleAssessment": {"roleObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "reorderedInputDeterministic": true, + "programGateCoverage": ["confirmedTerminal"], + "coverage": [{"logicalArtifactId":"server-mp-auth","state":"captured","requiredness":"required"}], + "artifactProvenance": [ + {"artifactId":"mp-auth-failure-current","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:auth-failure","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T02:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": "mp:request:28222222-2222-2222-2222-222222222222", + "transactions": [{ + "transactionId": "mp:request:28222222-2222-2222-2222-222222222222", + "key": {"keyProfileKind":"requestClientTopology","requestId":"28222222-2222-2222-2222-222222222222","policyId":null,"clientHandle":"safe:client:mp-auth-02","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","confidence":"exact","extractionProfileId":"mp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "observationKeyBinding": {"mode":"inheritImmutableParentTransactionKey","observationKeyFieldAllowed":false,"overrideAllowed":false}, + "phase": "authenticate", + "state": "failed", + "lastSuccessfulPhase": "receiveRequest", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [{"artifactId":"mp-auth-failure-current","startLine":1,"endLine":2}], + "observations": [ + {"observationId":"observation:auth:01-receive","phase":"receiveRequest","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 02:00:00.000+000","offsetMinutes":0,"utc":"2026-07-30T02:00:00.000Z"},"evidence":[{"artifactId":"mp-auth-failure-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:auth:02-failed","phase":"authenticate","state":"failed","classification":"confirmedFailure","timestamp":{"original":"7-30-2026 02:00:01.000+000","offsetMinutes":0,"utc":"2026-07-30T02:00:01.000Z"},"evidence":[{"artifactId":"mp-auth-failure-current","startLine":2,"endLine":2}]} + ] + }], + "sourceLocalObservations": [], + "contextFacts": [], + "findings": [{ + "findingId": "finding:mp-auth-failure", + "subjectId": "mp:request:28222222-2222-2222-2222-222222222222", + "class": "confirmedFailure", + "phase": "authenticate", + "lastSuccessfulPhase": "receiveRequest", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [{"artifactId":"mp-auth-failure-current","startLine":2,"endLine":2}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"]}, + "prohibitedClaims": ["arbitrary IIS tree required for analysis","client-side root cause or impact","cross-side correlation or #333 conclusion","management-point role absence from a missing default path","time-only or proximity-only transaction joining"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/manifest.json new file mode 100644 index 000000000..c4c0cf1f6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/manifest.json @@ -0,0 +1,39 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "managementPoint", + "artifactOrder": "role,designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "topology": { + "captureHost": "LAB-MP01", + "rolesObserved": ["managementPoint"], + "siteCode": "LAB", + "managementPointHostHandle": "safe:mp:lab-mp-01" + }, + "artifacts": [ + { + "artifactId": "mp-auth-failure-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log", + "pathFingerprint": "synthetic:auth-failure", + "rotation": {"kind":"current","lineageId":"mp-auth-failure","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T02:00:05Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 654, + "relativePath": "evidence/server-mp-auth/current/MP_GetAuth.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/canonical-intake-policy-scope/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/canonical-intake-policy-scope/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..570392b22 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/canonical-intake-policy-scope/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/canonical-intake-policy-scope/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/canonical-intake-policy-scope/manifest.json new file mode 100644 index 000000000..b8caa94af --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/canonical-intake-policy-scope/manifest.json @@ -0,0 +1,38 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-MP01", + "siteCode": "LAB", + "rolesObserved": ["managementPoint"] + }, + "artifacts": [ + { + "artifactId": "mp-policy-current", + "producerRole": "managementPoint", + "producerHostHandle": "synthetic:host:mp-01", + "sourceId": "server-mp-policy", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_MP_ROOT", + "originalBasename": "MP_GetPolicy.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:mp-default" + }, + "rotation": { + "kind": "current", + "lineageId": "mp-policy-lab" + }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T01:00:10Z", + "relativePath": "evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log", + "bytesCopied": 397 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-auth/current/MP_CliReg.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-auth/current/MP_CliReg.log new file mode 100644 index 000000000..21998286e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-auth/current/MP_CliReg.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-auth/current/MP_GetAuth.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-auth/current/MP_GetAuth.log new file mode 100644 index 000000000..74be0b93b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-auth/current/MP_GetAuth.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..57c92e1b2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/expected.json new file mode 100644 index 000000000..6175be402 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/expected.json @@ -0,0 +1,56 @@ +{ + "contractState": "proposedPending318And335", + "workflow": "managementPoint", + "scenario": "healthy-policy", + "stateChain": ["receiveRequest","authenticate","registerOrIdentify","resolveLocationOrPolicy","respond","recordOutcome"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"consumesSiteCoreOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"mp-server-5.00.test-v1","sourceVersionPrefix":"5.00.TEST","validatedArtifactFamilies":["server-mp-auth","server-mp-policy"],"validatedRole":"managementPoint"}, + "topology": {"role":"managementPoint","captureHostHandle":"safe:mp:lab-mp-01","siteCode":"LAB"}, + "roleAssessment": {"roleObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "reorderedInputDeterministic": true, + "programGateCoverage": ["blockedOrDeferred","completed"], + "coverage": [ + {"logicalArtifactId":"server-mp-auth","state":"captured","requiredness":"required"}, + {"logicalArtifactId":"server-mp-policy","state":"captured","requiredness":"required"} + ], + "artifactProvenance": [ + {"artifactId":"mp-healthy-auth-current","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:healthy-auth","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T01:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-healthy-policy-current","captureState":"captured","role":"managementPoint","producer":"MP_GetPolicy","pathFingerprint":"synthetic:healthy-policy","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T01:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-healthy-registration-current","captureState":"captured","role":"managementPoint","producer":"MP_CliReg","pathFingerprint":"synthetic:healthy-registration","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T01:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": "mp:request:28111111-1111-1111-1111-111111111111", + "transactions": [{ + "transactionId": "mp:request:28111111-1111-1111-1111-111111111111", + "key": {"keyProfileKind":"requestPolicyClientTopology","requestId":"28111111-1111-1111-1111-111111111111","policyId":"a8111111-1111-1111-1111-111111111111","clientHandle":"safe:client:mp-healthy-01","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","confidence":"exact","extractionProfileId":"mp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "observationKeyBinding": {"mode":"inheritImmutableParentTransactionKey","observationKeyFieldAllowed":false,"overrideAllowed":false}, + "phase": "recordOutcome", + "state": "succeeded", + "lastSuccessfulPhase": "recordOutcome", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"mp-healthy-auth-current","startLine":1,"endLine":2}, + {"artifactId":"mp-healthy-policy-current","startLine":1,"endLine":4}, + {"artifactId":"mp-healthy-registration-current","startLine":1,"endLine":1} + ], + "observations": [ + {"observationId":"observation:healthy:01-receive","phase":"receiveRequest","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 01:00:00.000+000","offsetMinutes":0,"utc":"2026-07-30T01:00:00.000Z"},"evidence":[{"artifactId":"mp-healthy-auth-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:healthy:02-authenticate","phase":"authenticate","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 01:00:01.000+000","offsetMinutes":0,"utc":"2026-07-30T01:00:01.000Z"},"evidence":[{"artifactId":"mp-healthy-auth-current","startLine":2,"endLine":2}]}, + {"observationId":"observation:healthy:03-register","phase":"registerOrIdentify","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 01:00:02.000+000","offsetMinutes":0,"utc":"2026-07-30T01:00:02.000Z"},"evidence":[{"artifactId":"mp-healthy-registration-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:healthy:04-resolve","phase":"resolveLocationOrPolicy","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 01:00:03.000+000","offsetMinutes":0,"utc":"2026-07-30T01:00:03.000Z"},"evidence":[{"artifactId":"mp-healthy-policy-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:healthy:05-respond-deferred","phase":"respond","state":"deferred","classification":"blockedOrDeferred","timestamp":{"original":"7-30-2026 01:00:04.000+000","offsetMinutes":0,"utc":"2026-07-30T01:00:04.000Z"},"evidence":[{"artifactId":"mp-healthy-policy-current","startLine":2,"endLine":2}]}, + {"observationId":"observation:healthy:06-respond-succeeded","phase":"respond","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 01:00:05.000+000","offsetMinutes":0,"utc":"2026-07-30T01:00:05.000Z"},"evidence":[{"artifactId":"mp-healthy-policy-current","startLine":3,"endLine":3}]}, + {"observationId":"observation:healthy:07-outcome","phase":"recordOutcome","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 01:00:06.000+000","offsetMinutes":0,"utc":"2026-07-30T01:00:06.000Z"},"evidence":[{"artifactId":"mp-healthy-policy-current","startLine":4,"endLine":4}]} + ] + }], + "sourceLocalObservations": [], + "contextFacts": [], + "findings": [], + "gateControls": {"blockedOrDeferredObservationId":"observation:healthy:05-respond-deferred","laterSameKeySuccess":true}, + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"]}, + "prohibitedClaims": ["arbitrary IIS tree required for analysis","client-side root cause or impact","cross-side correlation or #333 conclusion","management-point role absence from a missing default path","time-only or proximity-only transaction joining"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/manifest.json new file mode 100644 index 000000000..1e16ae7b8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/manifest.json @@ -0,0 +1,79 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "managementPoint", + "artifactOrder": "role,designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "topology": { + "captureHost": "LAB-MP01", + "rolesObserved": ["managementPoint"], + "siteCode": "LAB", + "managementPointHostHandle": "safe:mp:lab-mp-01" + }, + "artifacts": [ + { + "artifactId": "mp-healthy-auth-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log", + "pathFingerprint": "synthetic:healthy-auth", + "rotation": {"kind":"current","lineageId":"mp-healthy-auth","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T01:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 734, + "relativePath": "evidence/server-mp-auth/current/MP_GetAuth.log" + }, + { + "artifactId": "mp-healthy-registration-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_CliReg", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_CliReg.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_CliReg.log", + "pathFingerprint": "synthetic:healthy-registration", + "rotation": {"kind":"current","lineageId":"mp-healthy-registration","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T01:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 399, + "relativePath": "evidence/server-mp-auth/current/MP_CliReg.log" + }, + { + "artifactId": "mp-healthy-policy-current", + "designOnlyCatalog": {"entryId":"server-mp-policy","groupMemberships":["server-mp-policy"]}, + "role": "managementPoint", + "producer": "MP_GetPolicy", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetPolicy.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetPolicy.log", + "pathFingerprint": "synthetic:healthy-policy", + "rotation": {"kind":"current","lineageId":"mp-healthy-policy","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T01:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 1462, + "relativePath": "evidence/server-mp-policy/current/MP_GetPolicy.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-auth/current/MP_CliReg.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-auth/current/MP_CliReg.log new file mode 100644 index 000000000..16c7d9aa4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-auth/current/MP_CliReg.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-auth/current/MP_GetAuth.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-auth/current/MP_GetAuth.log new file mode 100644 index 000000000..041793ed8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-auth/current/MP_GetAuth.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..7c78b7681 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-policy/current/mpcontrol.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-policy/current/mpcontrol.log new file mode 100644 index 000000000..0a259da07 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-policy/current/mpcontrol.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/expected.json new file mode 100644 index 000000000..d8c4b201f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/expected.json @@ -0,0 +1,60 @@ +{ + "contractState": "proposedPending318And335", + "workflow": "managementPoint", + "scenario": "iis-supplemental", + "stateChain": ["receiveRequest","authenticate","registerOrIdentify","resolveLocationOrPolicy","respond","recordOutcome"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"consumesSiteCoreOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"mp-server-5.00.test-v1","sourceVersionPrefix":"5.00.TEST","validatedArtifactFamilies":["server-mp-auth","server-mp-policy"],"validatedRole":"managementPoint"}, + "topology": {"role":"managementPoint","captureHostHandle":"safe:mp:lab-mp-01","siteCode":"LAB"}, + "roleAssessment": {"roleObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "reorderedInputDeterministic": true, + "programGateCoverage": ["completed"], + "coverage": [ + {"logicalArtifactId":"server-mp-auth","state":"captured","requiredness":"required"}, + {"logicalArtifactId":"server-mp-iis","state":"skipped","requiredness":"optionalSupplemental"}, + {"logicalArtifactId":"server-mp-policy","state":"captured","requiredness":"required"} + ], + "artifactProvenance": [ + {"artifactId":"mp-iis-auth-current","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:iis-auth","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T06:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-iis-control-current","captureState":"captured","role":"siteServer","workflowSubjectRole":"managementPoint","producer":"SMS_MP_CONTROL_MANAGER","pathFingerprint":"synthetic:iis-role-context","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T06:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-iis-optional-skipped","captureState":"skipped","role":"managementPoint","producer":"IIS-W3C","pathFingerprint":"synthetic:iis-optional-not-requested","pathProvenance":"incidentBundleOptional","sourceVersion":"IIS.TEST.0000","collectedUtc":"2026-07-30T06:00:10Z","encoding":null,"byteLimit":null,"limitApplied":null}, + {"artifactId":"mp-iis-policy-current","captureState":"captured","role":"managementPoint","producer":"MP_GetPolicy","pathFingerprint":"synthetic:iis-policy-1","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T06:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-iis-registration-current","captureState":"captured","role":"managementPoint","producer":"MP_CliReg","pathFingerprint":"synthetic:iis-registration","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T06:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": "mp:request:28666666-6666-6666-6666-666666666666", + "transactions": [{ + "transactionId": "mp:request:28666666-6666-6666-6666-666666666666", + "key": {"keyProfileKind":"requestPolicyClientTopology","requestId":"28666666-6666-6666-6666-666666666666","policyId":"a8666666-6666-6666-6666-666666666666","clientHandle":"safe:client:mp-iis-06","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","confidence":"exact","extractionProfileId":"mp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "observationKeyBinding": {"mode":"inheritImmutableParentTransactionKey","observationKeyFieldAllowed":false,"overrideAllowed":false}, + "phase": "recordOutcome", + "state": "succeeded", + "lastSuccessfulPhase": "recordOutcome", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"mp-iis-auth-current","startLine":1,"endLine":2}, + {"artifactId":"mp-iis-policy-current","startLine":1,"endLine":3}, + {"artifactId":"mp-iis-registration-current","startLine":1,"endLine":1} + ], + "observations": [ + {"observationId":"observation:iis:01-receive","phase":"receiveRequest","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 06:00:00.000+000","offsetMinutes":0,"utc":"2026-07-30T06:00:00.000Z"},"evidence":[{"artifactId":"mp-iis-auth-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:iis:02-authenticate","phase":"authenticate","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 06:00:01.000+000","offsetMinutes":0,"utc":"2026-07-30T06:00:01.000Z"},"evidence":[{"artifactId":"mp-iis-auth-current","startLine":2,"endLine":2}]}, + {"observationId":"observation:iis:03-register","phase":"registerOrIdentify","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 06:00:02.000+000","offsetMinutes":0,"utc":"2026-07-30T06:00:02.000Z"},"evidence":[{"artifactId":"mp-iis-registration-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:iis:04-resolve","phase":"resolveLocationOrPolicy","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 06:00:03.000+000","offsetMinutes":0,"utc":"2026-07-30T06:00:03.000Z"},"evidence":[{"artifactId":"mp-iis-policy-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:iis:05-respond","phase":"respond","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 06:00:04.000+000","offsetMinutes":0,"utc":"2026-07-30T06:00:04.000Z"},"evidence":[{"artifactId":"mp-iis-policy-current","startLine":2,"endLine":2}]}, + {"observationId":"observation:iis:06-outcome","phase":"recordOutcome","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 06:00:05.000+000","offsetMinutes":0,"utc":"2026-07-30T06:00:05.000Z"},"evidence":[{"artifactId":"mp-iis-policy-current","startLine":3,"endLine":3}]} + ] + }], + "sourceLocalObservations": [], + "contextFacts": [ + {"factId":"fact:mp-control-role-context","classification":"contextOnly","transactionEligible":false,"correlationEligible":false,"evidence":[{"artifactId":"mp-iis-control-current","startLine":1,"endLine":1}]} + ], + "findings": [], + "arbitraryIisRequired": false, + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"]}, + "prohibitedClaims": ["arbitrary IIS tree required for analysis","client-side root cause or impact","cross-side correlation or #333 conclusion","management-point role absence from a missing default path","time-only or proximity-only transaction joining"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/manifest.json new file mode 100644 index 000000000..3c1682d7f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/manifest.json @@ -0,0 +1,118 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "managementPoint", + "artifactOrder": "role,designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "topology": { + "captureHost": "LAB-MP01", + "rolesObserved": ["managementPoint"], + "siteCode": "LAB", + "managementPointHostHandle": "safe:mp:lab-mp-01" + }, + "artifacts": [ + { + "artifactId": "mp-iis-auth-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log", + "pathFingerprint": "synthetic:iis-auth", + "rotation": {"kind":"current","lineageId":"mp-iis-auth","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T06:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 724, + "relativePath": "evidence/server-mp-auth/current/MP_GetAuth.log" + }, + { + "artifactId": "mp-iis-registration-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_CliReg", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_CliReg.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_CliReg.log", + "pathFingerprint": "synthetic:iis-registration", + "rotation": {"kind":"current","lineageId":"mp-iis-registration","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T06:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 393, + "relativePath": "evidence/server-mp-auth/current/MP_CliReg.log" + }, + { + "artifactId": "mp-iis-optional-skipped", + "designOnlyCatalog": {"entryId":"server-mp-iis","groupMemberships":["server-mp-iis"]}, + "role": "managementPoint", + "producer": "IIS-W3C", + "sourceKind": "iisW3c", + "captureState": "skipped", + "configuredPath": false, + "pathProvenance": "incidentBundleOptional", + "originalBasename": "u_ex260730.log", + "sanitizedSourcePath": null, + "pathFingerprint": "synthetic:iis-optional-not-requested", + "rotation": {"kind":"current","lineageId":"mp-iis-optional"}, + "sourceVersion": "IIS.TEST.0000", + "collectedUtc": "2026-07-30T06:00:10Z", + "bytesCopied": 0, + "relativePath": null + }, + { + "artifactId": "mp-iis-policy-current", + "designOnlyCatalog": {"entryId":"server-mp-policy","groupMemberships":["server-mp-policy"]}, + "role": "managementPoint", + "producer": "MP_GetPolicy", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetPolicy.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetPolicy.log", + "pathFingerprint": "synthetic:iis-policy-1", + "rotation": {"kind":"current","lineageId":"mp-iis-policy","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T06:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 1075, + "relativePath": "evidence/server-mp-policy/current/MP_GetPolicy.log" + }, + { + "artifactId": "mp-iis-control-current", + "designOnlyCatalog": {"entryId":"server-mp-policy","groupMemberships":["server-mp-policy"]}, + "role": "siteServer", + "workflowSubject": {"role":"managementPoint"}, + "producer": "SMS_MP_CONTROL_MANAGER", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "mpcontrol.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/mpcontrol.log", + "pathFingerprint": "synthetic:iis-role-context", + "rotation": {"kind":"current","lineageId":"mp-iis-control","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T06:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 233, + "relativePath": "evidence/server-mp-policy/current/mpcontrol.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/evidence/server-mp-auth/current/MP_CliReg.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/evidence/server-mp-auth/current/MP_CliReg.log new file mode 100644 index 000000000..c5dee0331 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/evidence/server-mp-auth/current/MP_CliReg.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/evidence/server-mp-auth/current/MP_GetAuth.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/evidence/server-mp-auth/current/MP_GetAuth.log new file mode 100644 index 000000000..70d0b7d2a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/evidence/server-mp-auth/current/MP_GetAuth.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/expected.json new file mode 100644 index 000000000..4639f41bf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/expected.json @@ -0,0 +1,52 @@ +{ + "contractState": "proposedPending318And335", + "workflow": "managementPoint", + "scenario": "incomplete", + "stateChain": ["receiveRequest","authenticate","registerOrIdentify","resolveLocationOrPolicy","respond","recordOutcome"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"consumesSiteCoreOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"mp-server-5.00.test-v1","sourceVersionPrefix":"5.00.TEST","validatedArtifactFamilies":["server-mp-auth","server-mp-policy"],"validatedRole":"managementPoint"}, + "topology": {"role":"managementPoint","captureHostHandle":"safe:mp:lab-mp-01","siteCode":"LAB"}, + "roleAssessment": {"roleObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "reorderedInputDeterministic": true, + "programGateCoverage": ["incomplete"], + "coverage": [ + {"logicalArtifactId":"server-mp-auth","state":"captured","requiredness":"required"}, + {"logicalArtifactId":"server-mp-policy","state":"absent","requiredness":"required"} + ], + "artifactProvenance": [ + {"artifactId":"mp-incomplete-auth-current","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:incomplete-auth","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T07:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-incomplete-policy-absent","captureState":"absent","role":"managementPoint","producer":"MP_GetPolicy","pathFingerprint":"synthetic:incomplete-policy-configured","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T07:00:05Z","encoding":null,"byteLimit":null,"limitApplied":null}, + {"artifactId":"mp-incomplete-registration-current","captureState":"captured","role":"managementPoint","producer":"MP_CliReg","pathFingerprint":"synthetic:incomplete-registration","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T07:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": "mp:request:28777777-7777-7777-7777-777777777777", + "transactions": [{ + "transactionId": "mp:request:28777777-7777-7777-7777-777777777777", + "key": {"keyProfileKind":"requestClientTopology","requestId":"28777777-7777-7777-7777-777777777777","policyId":null,"clientHandle":"safe:client:mp-incomplete-07","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","confidence":"exact","extractionProfileId":"mp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "observationKeyBinding": {"mode":"inheritImmutableParentTransactionKey","observationKeyFieldAllowed":false,"overrideAllowed":false}, + "phase": "registerOrIdentify", + "state": "incomplete", + "lastSuccessfulPhase": "registerOrIdentify", + "classification": "insufficientEvidence", + "confidence": "medium", + "confidenceCeiling": "medium", + "coverageGapArtifactIds": ["server-mp-policy"], + "nextArtifact": {"logicalArtifactId":"server-mp-policy","reason":"Capture bounded MP_Location or MP_GetPolicy evidence before evaluating later phases."}, + "evidence": [ + {"artifactId":"mp-incomplete-auth-current","startLine":1,"endLine":2}, + {"artifactId":"mp-incomplete-registration-current","startLine":1,"endLine":1} + ], + "observations": [ + {"observationId":"observation:incomplete:01-receive","phase":"receiveRequest","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 07:00:00.000+000","offsetMinutes":0,"utc":"2026-07-30T07:00:00.000Z"},"evidence":[{"artifactId":"mp-incomplete-auth-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:incomplete:02-authenticate","phase":"authenticate","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 07:00:01.000+000","offsetMinutes":0,"utc":"2026-07-30T07:00:01.000Z"},"evidence":[{"artifactId":"mp-incomplete-auth-current","startLine":2,"endLine":2}]}, + {"observationId":"observation:incomplete:03-register","phase":"registerOrIdentify","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 07:00:02.000+000","offsetMinutes":0,"utc":"2026-07-30T07:00:02.000Z"},"evidence":[{"artifactId":"mp-incomplete-registration-current","startLine":1,"endLine":1}]} + ] + }], + "sourceLocalObservations": [], + "contextFacts": [], + "findings": [ + {"findingId":"finding:mp-incomplete","subjectId":"mp:request:28777777-7777-7777-7777-777777777777","class":"insufficientEvidence","phase":"registerOrIdentify","lastSuccessfulPhase":"registerOrIdentify","confidence":"medium","confidenceCeiling":"medium","nextArtifact":{"logicalArtifactId":"server-mp-policy","reason":"Capture bounded MP_Location or MP_GetPolicy evidence before evaluating later phases."},"evidence":[{"artifactId":"mp-incomplete-registration-current","startLine":1,"endLine":1}]} + ], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"]}, + "prohibitedClaims": ["arbitrary IIS tree required for analysis","client-side root cause or impact","cross-side correlation or #333 conclusion","management-point role absence from a missing default path","time-only or proximity-only transaction joining"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/manifest.json new file mode 100644 index 000000000..8774b99ea --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/manifest.json @@ -0,0 +1,77 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "managementPoint", + "artifactOrder": "role,designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "topology": { + "captureHost": "LAB-MP01", + "rolesObserved": ["managementPoint"], + "siteCode": "LAB", + "managementPointHostHandle": "safe:mp:lab-mp-01" + }, + "artifacts": [ + { + "artifactId": "mp-incomplete-auth-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log", + "pathFingerprint": "synthetic:incomplete-auth", + "rotation": {"kind":"current","lineageId":"mp-incomplete-auth","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T07:00:05Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 640, + "relativePath": "evidence/server-mp-auth/current/MP_GetAuth.log" + }, + { + "artifactId": "mp-incomplete-registration-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_CliReg", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_CliReg.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_CliReg.log", + "pathFingerprint": "synthetic:incomplete-registration", + "rotation": {"kind":"current","lineageId":"mp-incomplete-registration","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T07:00:05Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 350, + "relativePath": "evidence/server-mp-auth/current/MP_CliReg.log" + }, + { + "artifactId": "mp-incomplete-policy-absent", + "designOnlyCatalog": {"entryId":"server-mp-policy","groupMemberships":["server-mp-policy"]}, + "role": "managementPoint", + "producer": "MP_GetPolicy", + "sourceKind": "ccmLog", + "captureState": "absent", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetPolicy.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetPolicy.log", + "pathFingerprint": "synthetic:incomplete-policy-configured", + "rotation": {"kind":"current","lineageId":"mp-incomplete-policy"}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T07:00:05Z", + "bytesCopied": 0, + "relativePath": null + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-auth/current/MP_CliReg.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-auth/current/MP_CliReg.log new file mode 100644 index 000000000..075a94148 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-auth/current/MP_CliReg.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-auth/current/MP_GetAuth.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-auth/current/MP_GetAuth.log new file mode 100644 index 000000000..dd91edc1e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-auth/current/MP_GetAuth.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-policy/current/MP_Location.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-policy/current/MP_Location.log new file mode 100644 index 000000000..fee964af7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-policy/current/MP_Location.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/expected.json new file mode 100644 index 000000000..b92eb59b4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/expected.json @@ -0,0 +1,62 @@ +{ + "contractState": "proposedPending318And335", + "workflow": "managementPoint", + "scenario": "location-failure", + "stateChain": ["receiveRequest","authenticate","registerOrIdentify","resolveLocationOrPolicy","respond","recordOutcome"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"consumesSiteCoreOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"mp-server-5.00.test-v1","sourceVersionPrefix":"5.00.TEST","validatedArtifactFamilies":["server-mp-auth","server-mp-policy"],"validatedRole":"managementPoint"}, + "topology": {"role":"managementPoint","captureHostHandle":"safe:mp:lab-mp-01","siteCode":"LAB"}, + "roleAssessment": {"roleObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "reorderedInputDeterministic": true, + "programGateCoverage": ["confirmedTerminal"], + "coverage": [ + {"logicalArtifactId":"server-mp-auth","state":"captured","requiredness":"required"}, + {"logicalArtifactId":"server-mp-policy","state":"captured","requiredness":"required"} + ], + "artifactProvenance": [ + {"artifactId":"mp-location-auth-current","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:location-auth","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T04:00:06Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-location-policy-current","captureState":"captured","role":"managementPoint","producer":"MP_Location","pathFingerprint":"synthetic:location-policy","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T04:00:06Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-location-registration-current","captureState":"captured","role":"managementPoint","producer":"MP_CliReg","pathFingerprint":"synthetic:location-registration","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T04:00:06Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": "mp:request:28444444-4444-4444-4444-444444444444", + "transactions": [{ + "transactionId": "mp:request:28444444-4444-4444-4444-444444444444", + "key": {"keyProfileKind":"requestClientTopology","requestId":"28444444-4444-4444-4444-444444444444","policyId":null,"clientHandle":"safe:client:mp-location-04","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","confidence":"exact","extractionProfileId":"mp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "observationKeyBinding": {"mode":"inheritImmutableParentTransactionKey","observationKeyFieldAllowed":false,"overrideAllowed":false}, + "phase": "resolveLocationOrPolicy", + "state": "failed", + "lastSuccessfulPhase": "registerOrIdentify", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"mp-location-auth-current","startLine":1,"endLine":2}, + {"artifactId":"mp-location-policy-current","startLine":1,"endLine":1}, + {"artifactId":"mp-location-registration-current","startLine":1,"endLine":1} + ], + "observations": [ + {"observationId":"observation:location:01-receive","phase":"receiveRequest","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 04:00:00.000+000","offsetMinutes":0,"utc":"2026-07-30T04:00:00.000Z"},"evidence":[{"artifactId":"mp-location-auth-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:location:02-authenticate","phase":"authenticate","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 04:00:01.000+000","offsetMinutes":0,"utc":"2026-07-30T04:00:01.000Z"},"evidence":[{"artifactId":"mp-location-auth-current","startLine":2,"endLine":2}]}, + {"observationId":"observation:location:03-register","phase":"registerOrIdentify","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 04:00:02.000+000","offsetMinutes":0,"utc":"2026-07-30T04:00:02.000Z"},"evidence":[{"artifactId":"mp-location-registration-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:location:04-failed","phase":"resolveLocationOrPolicy","state":"failed","classification":"confirmedFailure","timestamp":{"original":"7-30-2026 04:00:03.000+000","offsetMinutes":0,"utc":"2026-07-30T04:00:03.000Z"},"evidence":[{"artifactId":"mp-location-policy-current","startLine":1,"endLine":1}]} + ] + }], + "sourceLocalObservations": [], + "contextFacts": [], + "findings": [{ + "findingId": "finding:mp-location-failure", + "subjectId": "mp:request:28444444-4444-4444-4444-444444444444", + "class": "confirmedFailure", + "phase": "resolveLocationOrPolicy", + "lastSuccessfulPhase": "registerOrIdentify", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [{"artifactId":"mp-location-policy-current","startLine":1,"endLine":1}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"]}, + "prohibitedClaims": ["arbitrary IIS tree required for analysis","client-side root cause or impact","cross-side correlation or #333 conclusion","management-point role absence from a missing default path","time-only or proximity-only transaction joining"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/manifest.json new file mode 100644 index 000000000..188a963ae --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/manifest.json @@ -0,0 +1,79 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "managementPoint", + "artifactOrder": "role,designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "topology": { + "captureHost": "LAB-MP01", + "rolesObserved": ["managementPoint"], + "siteCode": "LAB", + "managementPointHostHandle": "safe:mp:lab-mp-01" + }, + "artifacts": [ + { + "artifactId": "mp-location-auth-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log", + "pathFingerprint": "synthetic:location-auth", + "rotation": {"kind":"current","lineageId":"mp-location-auth","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T04:00:06Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 642, + "relativePath": "evidence/server-mp-auth/current/MP_GetAuth.log" + }, + { + "artifactId": "mp-location-registration-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_CliReg", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_CliReg.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_CliReg.log", + "pathFingerprint": "synthetic:location-registration", + "rotation": {"kind":"current","lineageId":"mp-location-registration","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T04:00:06Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 354, + "relativePath": "evidence/server-mp-auth/current/MP_CliReg.log" + }, + { + "artifactId": "mp-location-policy-current", + "designOnlyCatalog": {"entryId":"server-mp-policy","groupMemberships":["server-mp-policy"]}, + "role": "managementPoint", + "producer": "MP_Location", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_Location.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_Location.log", + "pathFingerprint": "synthetic:location-policy", + "rotation": {"kind":"current","lineageId":"mp-location-policy","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T04:00:06Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 372, + "relativePath": "evidence/server-mp-policy/current/MP_Location.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-auth/current/MP_CliReg.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-auth/current/MP_CliReg.log new file mode 100644 index 000000000..17b0d8e3b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-auth/current/MP_CliReg.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-auth/current/MP_GetAuth.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-auth/current/MP_GetAuth.log new file mode 100644 index 000000000..d92248f89 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-auth/current/MP_GetAuth.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..5d9dc10d5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/expected.json new file mode 100644 index 000000000..a06bef5bf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/expected.json @@ -0,0 +1,110 @@ +{ + "contractState": "proposedPending318And335", + "workflow": "managementPoint", + "scenario": "policy-failure", + "stateChain": ["receiveRequest","authenticate","registerOrIdentify","resolveLocationOrPolicy","respond","recordOutcome"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"consumesSiteCoreOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"mp-server-5.00.test-v1","sourceVersionPrefix":"5.00.TEST","validatedArtifactFamilies":["server-mp-auth","server-mp-policy"],"validatedRole":"managementPoint"}, + "topology": {"role":"managementPoint","captureHostHandle":"safe:mp:lab-mp-01","siteCode":"LAB"}, + "roleAssessment": {"roleObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "reorderedInputDeterministic": true, + "programGateCoverage": ["confirmedTerminal","contradictory"], + "coverage": [ + {"logicalArtifactId":"server-mp-auth","state":"captured","requiredness":"required"}, + {"logicalArtifactId":"server-mp-policy","state":"captured","requiredness":"required"} + ], + "artifactProvenance": [ + {"artifactId":"mp-policy-auth-current","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:policy-auth","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T05:10:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-policy-registration-current","captureState":"captured","role":"managementPoint","producer":"MP_CliReg","pathFingerprint":"synthetic:policy-registration","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T05:10:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-policy-response-current","captureState":"captured","role":"managementPoint","producer":"MP_GetPolicy","pathFingerprint":"synthetic:policy-response","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T05:10:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": "mp:request:28555555-5555-5555-5555-555555555555", + "transactions": [ + { + "transactionId": "mp:request:28555555-5555-5555-5555-555555555555", + "key": {"keyProfileKind":"requestPolicyClientTopology","requestId":"28555555-5555-5555-5555-555555555555","policyId":"a8555555-5555-5555-5555-555555555555","clientHandle":"safe:client:mp-policy-primary-05","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","confidence":"exact","extractionProfileId":"mp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "observationKeyBinding": {"mode":"inheritImmutableParentTransactionKey","observationKeyFieldAllowed":false,"overrideAllowed":false}, + "phase": "respond", + "state": "failed", + "lastSuccessfulPhase": "resolveLocationOrPolicy", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"mp-policy-auth-current","startLine":1,"endLine":2}, + {"artifactId":"mp-policy-registration-current","startLine":1,"endLine":1}, + {"artifactId":"mp-policy-response-current","startLine":1,"endLine":2} + ], + "observations": [ + {"observationId":"observation:policy-primary:01-receive","phase":"receiveRequest","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 05:00:00.000+000","offsetMinutes":0,"utc":"2026-07-30T05:00:00.000Z"},"evidence":[{"artifactId":"mp-policy-auth-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:policy-primary:02-authenticate","phase":"authenticate","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 05:00:01.000+000","offsetMinutes":0,"utc":"2026-07-30T05:00:01.000Z"},"evidence":[{"artifactId":"mp-policy-auth-current","startLine":2,"endLine":2}]}, + {"observationId":"observation:policy-primary:03-register","phase":"registerOrIdentify","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 05:00:02.000+000","offsetMinutes":0,"utc":"2026-07-30T05:00:02.000Z"},"evidence":[{"artifactId":"mp-policy-registration-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:policy-primary:04-resolve","phase":"resolveLocationOrPolicy","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 05:00:03.000+000","offsetMinutes":0,"utc":"2026-07-30T05:00:03.000Z"},"evidence":[{"artifactId":"mp-policy-response-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:policy-primary:05-failed","phase":"respond","state":"failed","classification":"confirmedFailure","timestamp":{"original":"7-30-2026 05:00:04.000+000","offsetMinutes":0,"utc":"2026-07-30T05:00:04.000Z"},"evidence":[{"artifactId":"mp-policy-response-current","startLine":2,"endLine":2}]} + ] + }, + { + "transactionId": "mp:request:28555556-5555-5555-5555-555555555556", + "key": {"keyProfileKind":"requestPolicyClientTopology","requestId":"28555556-5555-5555-5555-555555555556","policyId":"a8555556-5555-5555-5555-555555555556","clientHandle":"safe:client:mp-policy-control-05","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","confidence":"exact","extractionProfileId":"mp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "observationKeyBinding": {"mode":"inheritImmutableParentTransactionKey","observationKeyFieldAllowed":false,"overrideAllowed":false}, + "phase": "respond", + "state": "contradictory", + "lastSuccessfulPhase": "resolveLocationOrPolicy", + "classification": "contradictoryEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "coverageGapArtifactIds": [], + "nextArtifact": {"logicalArtifactId":"server-mp-policy","reason":"Recapture bounded MP_GetPolicy evidence to resolve the same-instant response contradiction."}, + "evidence": [ + {"artifactId":"mp-policy-auth-current","startLine":3,"endLine":4}, + {"artifactId":"mp-policy-registration-current","startLine":2,"endLine":2}, + {"artifactId":"mp-policy-response-current","startLine":3,"endLine":5} + ], + "observations": [ + {"observationId":"observation:policy-control:01-receive","phase":"receiveRequest","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 05:10:00.000+000","offsetMinutes":0,"utc":"2026-07-30T05:10:00.000Z"},"evidence":[{"artifactId":"mp-policy-auth-current","startLine":3,"endLine":3}]}, + {"observationId":"observation:policy-control:02-authenticate","phase":"authenticate","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 05:10:01.000+000","offsetMinutes":0,"utc":"2026-07-30T05:10:01.000Z"},"evidence":[{"artifactId":"mp-policy-auth-current","startLine":4,"endLine":4}]}, + {"observationId":"observation:policy-control:03-register","phase":"registerOrIdentify","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 05:10:02.000+000","offsetMinutes":0,"utc":"2026-07-30T05:10:02.000Z"},"evidence":[{"artifactId":"mp-policy-registration-current","startLine":2,"endLine":2}]}, + {"observationId":"observation:policy-control:04-resolve","phase":"resolveLocationOrPolicy","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 05:10:03.000+000","offsetMinutes":0,"utc":"2026-07-30T05:10:03.000Z"},"evidence":[{"artifactId":"mp-policy-response-current","startLine":3,"endLine":3}]}, + {"observationId":"observation:policy-control:05-respond-success","phase":"respond","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 05:10:04.000+000","offsetMinutes":0,"utc":"2026-07-30T05:10:04.000Z"},"evidence":[{"artifactId":"mp-policy-response-current","startLine":4,"endLine":4}]}, + {"observationId":"observation:policy-control:06-respond-failure","phase":"respond","state":"failed","classification":"confirmedFailure","timestamp":{"original":"7-30-2026 05:10:04.000+000","offsetMinutes":0,"utc":"2026-07-30T05:10:04.000Z"},"evidence":[{"artifactId":"mp-policy-response-current","startLine":5,"endLine":5}]} + ] + } + ], + "sourceLocalObservations": [], + "contextFacts": [], + "findings": [ + { + "findingId": "finding:mp-policy-control-contradictory", + "subjectId": "mp:request:28555556-5555-5555-5555-555555555556", + "class": "contradictoryEvidence", + "phase": "respond", + "lastSuccessfulPhase": "resolveLocationOrPolicy", + "confidence": "low", + "confidenceCeiling": "low", + "nextArtifact": {"logicalArtifactId":"server-mp-policy","reason":"Recapture bounded MP_GetPolicy evidence to resolve the same-instant response contradiction."}, + "evidence": [{"artifactId":"mp-policy-response-current","startLine":4,"endLine":5}] + }, + { + "findingId": "finding:mp-policy-primary-failure", + "subjectId": "mp:request:28555555-5555-5555-5555-555555555555", + "class": "confirmedFailure", + "phase": "respond", + "lastSuccessfulPhase": "resolveLocationOrPolicy", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [{"artifactId":"mp-policy-response-current","startLine":2,"endLine":2}] + } + ], + "gateControls": { + "contradictoryTransactionId": "mp:request:28555556-5555-5555-5555-555555555556", + "primaryFindingId": "finding:mp-policy-primary-failure", + "controlCannotUpgradePrimary": true + }, + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"]}, + "prohibitedClaims": ["arbitrary IIS tree required for analysis","client-side root cause or impact","cross-side correlation or #333 conclusion","management-point role absence from a missing default path","time-only or proximity-only transaction joining"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/manifest.json new file mode 100644 index 000000000..a641be5db --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/manifest.json @@ -0,0 +1,79 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "managementPoint", + "artifactOrder": "role,designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "topology": { + "captureHost": "LAB-MP01", + "rolesObserved": ["managementPoint"], + "siteCode": "LAB", + "managementPointHostHandle": "safe:mp:lab-mp-01" + }, + "artifacts": [ + { + "artifactId": "mp-policy-auth-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log", + "pathFingerprint": "synthetic:policy-auth", + "rotation": {"kind":"current","lineageId":"mp-policy-auth","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T05:10:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 1467, + "relativePath": "evidence/server-mp-auth/current/MP_GetAuth.log" + }, + { + "artifactId": "mp-policy-registration-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_CliReg", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_CliReg.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_CliReg.log", + "pathFingerprint": "synthetic:policy-registration", + "rotation": {"kind":"current","lineageId":"mp-policy-registration","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T05:10:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 783, + "relativePath": "evidence/server-mp-auth/current/MP_CliReg.log" + }, + { + "artifactId": "mp-policy-response-current", + "designOnlyCatalog": {"entryId":"server-mp-policy","groupMemberships":["server-mp-policy"]}, + "role": "managementPoint", + "producer": "MP_GetPolicy", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetPolicy.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetPolicy.log", + "pathFingerprint": "synthetic:policy-response", + "rotation": {"kind":"current","lineageId":"mp-policy-response","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T05:10:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 1872, + "relativePath": "evidence/server-mp-policy/current/MP_GetPolicy.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/evidence/server-mp-auth/current/MP_GetAuth.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/evidence/server-mp-auth/current/MP_GetAuth.log new file mode 100644 index 000000000..9186dee3d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/evidence/server-mp-auth/current/MP_GetAuth.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/evidence/server-mp-auth/current/MP_RegistrationManager.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/evidence/server-mp-auth/current/MP_RegistrationManager.log new file mode 100644 index 000000000..914baf359 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/evidence/server-mp-auth/current/MP_RegistrationManager.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/expected.json new file mode 100644 index 000000000..d6b10c478 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/expected.json @@ -0,0 +1,56 @@ +{ + "contractState": "proposedPending318And335", + "workflow": "managementPoint", + "scenario": "registration-failure", + "stateChain": ["receiveRequest","authenticate","registerOrIdentify","resolveLocationOrPolicy","respond","recordOutcome"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"consumesSiteCoreOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selected","profileId":"mp-server-5.00.test-v1","sourceVersionPrefix":"5.00.TEST","validatedArtifactFamilies":["server-mp-auth","server-mp-policy"],"validatedRole":"managementPoint"}, + "topology": {"role":"managementPoint","captureHostHandle":"safe:mp:lab-mp-01","siteCode":"LAB"}, + "roleAssessment": {"roleObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "reorderedInputDeterministic": true, + "programGateCoverage": ["confirmedTerminal"], + "coverage": [{"logicalArtifactId":"server-mp-auth","state":"captured","requiredness":"required"}], + "artifactProvenance": [ + {"artifactId":"mp-registration-auth-current","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:registration-auth","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T03:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-registration-manager-current","captureState":"captured","role":"managementPoint","producer":"MP_RegistrationManager","pathFingerprint":"synthetic:registration-manager","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T03:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": "mp:request:28333333-3333-3333-3333-333333333333", + "transactions": [{ + "transactionId": "mp:request:28333333-3333-3333-3333-333333333333", + "key": {"keyProfileKind":"requestClientTopology","requestId":"28333333-3333-3333-3333-333333333333","policyId":null,"clientHandle":"safe:client:mp-registration-03","siteCode":"LAB","managementPointHostHandle":"safe:mp:lab-mp-01","confidence":"exact","extractionProfileId":"mp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "observationKeyBinding": {"mode":"inheritImmutableParentTransactionKey","observationKeyFieldAllowed":false,"overrideAllowed":false}, + "phase": "registerOrIdentify", + "state": "failed", + "lastSuccessfulPhase": "authenticate", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "coverageGapArtifactIds": [], + "nextArtifact": null, + "evidence": [ + {"artifactId":"mp-registration-auth-current","startLine":1,"endLine":2}, + {"artifactId":"mp-registration-manager-current","startLine":1,"endLine":1} + ], + "observations": [ + {"observationId":"observation:registration:01-receive","phase":"receiveRequest","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 03:00:00.000+000","offsetMinutes":0,"utc":"2026-07-30T03:00:00.000Z"},"evidence":[{"artifactId":"mp-registration-auth-current","startLine":1,"endLine":1}]}, + {"observationId":"observation:registration:02-authenticate","phase":"authenticate","state":"succeeded","classification":"success","timestamp":{"original":"7-30-2026 03:00:01.000+000","offsetMinutes":0,"utc":"2026-07-30T03:00:01.000Z"},"evidence":[{"artifactId":"mp-registration-auth-current","startLine":2,"endLine":2}]}, + {"observationId":"observation:registration:03-failed","phase":"registerOrIdentify","state":"failed","classification":"confirmedFailure","timestamp":{"original":"7-30-2026 03:00:02.000+000","offsetMinutes":0,"utc":"2026-07-30T03:00:02.000Z"},"evidence":[{"artifactId":"mp-registration-manager-current","startLine":1,"endLine":1}]} + ] + }], + "sourceLocalObservations": [], + "contextFacts": [], + "findings": [{ + "findingId": "finding:mp-registration-failure", + "subjectId": "mp:request:28333333-3333-3333-3333-333333333333", + "class": "confirmedFailure", + "phase": "registerOrIdentify", + "lastSuccessfulPhase": "authenticate", + "confidence": "high", + "confidenceCeiling": "high", + "nextArtifact": null, + "evidence": [{"artifactId":"mp-registration-manager-current","startLine":1,"endLine":1}] + }], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"]}, + "prohibitedClaims": ["arbitrary IIS tree required for analysis","client-side root cause or impact","cross-side correlation or #333 conclusion","management-point role absence from a missing default path","time-only or proximity-only transaction joining"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/manifest.json new file mode 100644 index 000000000..18b934a8e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/manifest.json @@ -0,0 +1,59 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "managementPoint", + "artifactOrder": "role,designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "topology": { + "captureHost": "LAB-MP01", + "rolesObserved": ["managementPoint"], + "siteCode": "LAB", + "managementPointHostHandle": "safe:mp:lab-mp-01" + }, + "artifacts": [ + { + "artifactId": "mp-registration-auth-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log", + "pathFingerprint": "synthetic:registration-auth", + "rotation": {"kind":"current","lineageId":"mp-registration-auth","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T03:00:05Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 654, + "relativePath": "evidence/server-mp-auth/current/MP_GetAuth.log" + }, + { + "artifactId": "mp-registration-manager-current", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_RegistrationManager", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_RegistrationManager.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_RegistrationManager.log", + "pathFingerprint": "synthetic:registration-manager", + "rotation": {"kind":"current","lineageId":"mp-registration-manager","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T03:00:05Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 399, + "relativePath": "evidence/server-mp-auth/current/MP_RegistrationManager.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/evidence/server-mp-auth/current/MP_GetAuth.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/evidence/server-mp-auth/current/MP_GetAuth.log new file mode 100644 index 000000000..d9eec7bb7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/evidence/server-mp-auth/current/MP_GetAuth.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/expected.json new file mode 100644 index 000000000..a7cd947ad --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/expected.json @@ -0,0 +1,34 @@ +{ + "contractState": "proposedPending318And335", + "workflow": "managementPoint", + "scenario": "rotation-boundary", + "stateChain": ["receiveRequest","authenticate","registerOrIdentify","resolveLocationOrPolicy","respond","recordOutcome"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"consumesSiteCoreOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"unvalidatedVersion","profileId":null,"sourceVersionPrefix":null,"validatedArtifactFamilies":[],"validatedRole":"managementPoint"}, + "topology": {"role":"managementPoint","captureHostHandle":"safe:mp:lab-mp-01","siteCode":"LAB"}, + "roleAssessment": {"roleObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "reorderedInputDeterministic": true, + "programGateCoverage": ["malformed","rotation"], + "coverage": [ + {"logicalArtifactId":"server-mp-auth","state":"captured","requiredness":"required"} + ], + "artifactProvenance": [ + {"artifactId":"mp-rotation-current-fragment","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:rotation-auth-root","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T09:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-rotation-lo-fragment","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:rotation-auth-root","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T09:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-rotation-numbered-malformed","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:rotation-auth-root","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.UNKNOWN.0000","collectedUtc":"2026-07-30T09:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": null, + "transactions": [], + "sourceLocalObservations": [ + {"observationId":"observation:rotation-fragments","key":null,"keyConfidence":"none","classification":"insufficientEvidence","phase":"receiveRequest","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","correlationEligible":false,"borrowedKeys":false,"fragmentOnly":true,"nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Collect a complete bounded record; physical rotation fragments are coverage-only."},"evidence":[{"artifactId":"mp-rotation-current-fragment","startLine":1,"endLine":1},{"artifactId":"mp-rotation-lo-fragment","startLine":1,"endLine":1}]}, + {"observationId":"observation:rotation-malformed:server-mp-auth","key":null,"keyConfidence":"none","classification":"lowConfidenceSymptom","phase":"receiveRequest","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","correlationEligible":false,"borrowedKeys":false,"profileSelectionState":"unvalidatedVersion","malformedKey":true,"nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Collect a supported-version record containing a complete exact request key."},"evidence":[{"artifactId":"mp-rotation-numbered-malformed","startLine":1,"endLine":1}]} + ], + "contextFacts": [], + "findings": [ + {"findingId":"finding:mp-rotation-fragments","subjectId":"observation:rotation-fragments","class":"insufficientEvidence","phase":"receiveRequest","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Collect a complete bounded record; physical rotation fragments are coverage-only."},"evidence":[{"artifactId":"mp-rotation-current-fragment","startLine":1,"endLine":1},{"artifactId":"mp-rotation-lo-fragment","startLine":1,"endLine":1}]}, + {"findingId":"finding:rotation-malformed:server-mp-auth","subjectId":"observation:rotation-malformed:server-mp-auth","class":"lowConfidenceSymptom","phase":"receiveRequest","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Collect a supported-version record containing a complete exact request key."},"evidence":[{"artifactId":"mp-rotation-numbered-malformed","startLine":1,"endLine":1}]} + ], + "adjacentKeyBorrowingAllowed": false, + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"]}, + "prohibitedClaims": ["arbitrary IIS tree required for analysis","client-side root cause or impact","cross-side correlation or #333 conclusion","management-point role absence from a missing default path","time-only or proximity-only transaction joining"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/manifest.json new file mode 100644 index 000000000..3ad9a34bb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/manifest.json @@ -0,0 +1,79 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "managementPoint", + "artifactOrder": "role,designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "topology": { + "captureHost": "LAB-MP01", + "rolesObserved": ["managementPoint"], + "siteCode": "LAB", + "managementPointHostHandle": "safe:mp:lab-mp-01" + }, + "artifacts": [ + { + "artifactId": "mp-rotation-current-fragment", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log", + "pathFingerprint": "synthetic:rotation-auth-root", + "rotation": {"kind":"current","lineageId":"mp-rotation-split","fragmentComplete":false}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T09:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 147, + "relativePath": "evidence/server-mp-auth/current/MP_GetAuth.log" + }, + { + "artifactId": "mp-rotation-lo-fragment", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.lo_", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.lo_", + "pathFingerprint": "synthetic:rotation-auth-root", + "rotation": {"kind":"lo","lineageId":"mp-rotation-split","fragmentComplete":false}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T09:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 136, + "relativePath": "evidence/server-mp-auth/lo/MP_GetAuth.lo_" + }, + { + "artifactId": "mp-rotation-numbered-malformed", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log.2", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log.2", + "pathFingerprint": "synthetic:rotation-auth-root", + "rotation": {"kind":"numbered","value":2,"lineageId":"mp-rotation-malformed","fragmentComplete":true}, + "sourceVersion": "5.00.UNKNOWN.0000", + "collectedUtc": "2026-07-30T09:00:10Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 319, + "relativePath": "evidence/server-mp-auth/numbered-2/MP_GetAuth.log.2" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/evidence/server-mp-policy/current/MP_GetPolicy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/evidence/server-mp-policy/current/MP_GetPolicy.log new file mode 100644 index 000000000..6d1f1ba1a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/evidence/server-mp-policy/current/MP_GetPolicy.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/expected.json new file mode 100644 index 000000000..8b374d1a3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/expected.json @@ -0,0 +1,35 @@ +{ + "contractState": "proposedPending318And335", + "workflow": "managementPoint", + "scenario": "unrelated-client-like-key", + "stateChain": ["receiveRequest","authenticate","registerOrIdentify","resolveLocationOrPolicy","respond","recordOutcome"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"consumesSiteCoreOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedNoCompatibleTransaction","profileId":"mp-server-5.00.test-v1","sourceVersionPrefix":"5.00.TEST","validatedArtifactFamilies":["server-mp-auth","server-mp-policy"],"validatedRole":"managementPoint"}, + "topology": {"role":"managementPoint","captureHostHandle":"safe:mp:lab-mp-01","siteCode":"LAB"}, + "roleAssessment": {"roleObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "reorderedInputDeterministic": true, + "programGateCoverage": [], + "coverage": [ + {"logicalArtifactId":"server-mp-auth","state":"absent","requiredness":"required"}, + {"logicalArtifactId":"server-mp-policy","state":"captured","requiredness":"required"} + ], + "artifactProvenance": [ + {"artifactId":"mp-unrelated-auth-absent","captureState":"absent","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:unrelated-auth-configured","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T08:00:05Z","encoding":null,"byteLimit":null,"limitApplied":null}, + {"artifactId":"mp-unrelated-policy-current","captureState":"captured","role":"managementPoint","producer":"MP_GetPolicy","pathFingerprint":"synthetic:unrelated-policy","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST","collectedUtc":"2026-07-30T08:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": null, + "transactions": [], + "sourceLocalObservations": [ + {"observationId":"observation:coverage:server-mp-auth","key":null,"keyConfidence":"none","classification":"insufficientEvidence","phase":"receiveRequest","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","correlationEligible":false,"borrowedKeys":false,"nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Capture bounded MP_GetAuth.log coverage before evaluating Management Point requests."},"evidence":[]}, + {"observationId":"observation:unrelated-client-like-key:server-mp-policy","key":null,"keyConfidence":"none","classification":"incompatibleKey","phase":"resolveLocationOrPolicy","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","correlationEligible":false,"borrowedKeys":false,"nextArtifact":{"logicalArtifactId":"server-mp-policy","reason":"Capture a bounded policy-family source with the exact versioned request key."},"evidence":[{"artifactId":"mp-unrelated-policy-current","startLine":1,"endLine":1}]} + ], + "contextFacts": [], + "findings": [ + {"findingId":"finding:mp-coverage:server-mp-auth","subjectId":"observation:coverage:server-mp-auth","class":"insufficientEvidence","phase":"receiveRequest","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Capture bounded MP_GetAuth.log coverage before evaluating Management Point requests."},"evidence":[]}, + {"findingId":"finding:unrelated-client-like-key:server-mp-policy","subjectId":"observation:unrelated-client-like-key:server-mp-policy","class":"lowConfidenceSymptom","phase":"resolveLocationOrPolicy","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","nextArtifact":{"logicalArtifactId":"server-mp-policy","reason":"Capture a bounded policy-family source with the exact versioned request key."},"evidence":[{"artifactId":"mp-unrelated-policy-current","startLine":1,"endLine":1}]} + ], + "clientLikeTokensAttached": false, + "timeProximityUsed": false, + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false,"counterpartReadyKeyKinds":["requestId","policyId","clientSafeHandle","siteCode","managementPointHostHandle"]}, + "prohibitedClaims": ["arbitrary IIS tree required for analysis","client-side root cause or impact","cross-side correlation or #333 conclusion","management-point role absence from a missing default path","time-only or proximity-only transaction joining"] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/manifest.json new file mode 100644 index 000000000..2476e6a9f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/manifest.json @@ -0,0 +1,57 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "bundleRole": "server", + "workflow": "managementPoint", + "artifactOrder": "role,designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "topology": { + "captureHost": "LAB-MP01", + "rolesObserved": ["managementPoint"], + "siteCode": "LAB", + "managementPointHostHandle": "safe:mp:lab-mp-01" + }, + "artifacts": [ + { + "artifactId": "mp-unrelated-auth-absent", + "designOnlyCatalog": {"entryId":"server-mp-auth","groupMemberships":["server-mp-auth"]}, + "role": "managementPoint", + "producer": "MP_GetAuth", + "sourceKind": "ccmLog", + "captureState": "absent", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetAuth.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetAuth.log", + "pathFingerprint": "synthetic:unrelated-auth-configured", + "rotation": {"kind":"current","lineageId":"mp-unrelated-auth"}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T08:00:05Z", + "bytesCopied": 0, + "relativePath": null + }, + { + "artifactId": "mp-unrelated-policy-current", + "designOnlyCatalog": {"entryId":"server-mp-policy","groupMemberships":["server-mp-policy"]}, + "role": "managementPoint", + "producer": "MP_GetPolicy", + "sourceKind": "ccmLog", + "captureState": "captured", + "configuredPath": true, + "pathProvenance": "configuredSynthetic", + "originalBasename": "MP_GetPolicy.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/MP/Logs/MP_GetPolicy.log", + "pathFingerprint": "synthetic:unrelated-policy", + "rotation": {"kind":"current","lineageId":"mp-unrelated-policy","fragmentComplete":true}, + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T08:00:05Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 402, + "relativePath": "evidence/server-mp-policy/current/MP_GetPolicy.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/README.md new file mode 100644 index 000000000..e54c451b8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/README.md @@ -0,0 +1,74 @@ +# Provider and Admin Service preparation corpus (#332) + +This corpus is synthetic, sanitized, and preparation-only. It freezes the +source, privacy, topology, key, evidence, coverage, and state expectations for +issue #332 without adding a production reducer or native collector. + +The shared CCM parser remains the transport grammar for `Smsprov.log` and +`AdminService.log`. The scoped IIS file is supplemental W3C evidence only. It +is deliberately excluded from CCM normalization and cannot create or complete +an Admin Service transaction. + +## Proposed source groups + +| Source ID | Basename | Producer role | Layer | Diagnostic use | +| --- | --- | --- | --- | --- | +| `server-provider` | `Smsprov.log` | `provider` | Provider | primary CCM | +| `server-admin-service` | `AdminService.log` | `provider` | Admin Service | primary CCM | +| `server-admin-service-iis` | `u_ex_synthetic.log` | `provider` | supplemental IIS | optional context only | + +`AdminService.log` retains the catalogued producer role `provider`; the +workflow layer is separately recorded as `adminService`. A filename alone +cannot invent a role, endpoint, or installed component. + +## Scenario matrix + +| Scenario | Layer/outcome | Conservative control | +| --- | --- | --- | +| `provider-success` | Provider terminal success | all five Provider phases are line-cited | +| `provider-authz-denied` | Provider terminal failure | explicit authorization evidence; no caller identity in public output | +| `provider-query-failure` | Provider terminal failure | operation failure is source-specific; query text is not a key | +| `provider-retry` | Provider retry then terminal success | one cited retryable operation failure must recover on the same exact key before terminal success | +| `provider-timeout` | Provider incomplete | invalid offset and no terminal outcome keep confidence low | +| `provider-source-absent` | Provider coverage only | absent source requests only bounded Provider evidence | +| `provider-source-capped` | Provider coverage only | capped partial bytes cannot form a transaction or outcome | +| `provider-source-unsupported` | Provider coverage only | unsupported source/profile cannot form an exact key | +| `contradictory-evidence` | Provider contradictory terminal outcomes | every admitted same-key record is cited; conflicting terminal results stay incomplete and low-confidence | +| `admin-service-success` | Admin Service terminal success | six-stage Admin Service grammar is independent | +| `admin-service-auth-failure` | Admin Service terminal failure | explicit authentication failure only | +| `admin-service-backend-failure` | Admin Service terminal failure | backend evidence does not claim client or console impact | +| `admin-service-access-denied` | Admin Service coverage only | access denial is not workflow failure evidence | +| `admin-service-parse-failed` | Admin Service coverage only | malformed evidence requests bounded recapture/repair | +| `admin-service-skipped` | Admin Service coverage only | skipped collection is not a workflow outcome | +| `blocked-deferred` | Admin Service incomplete | pending evidence stays low-confidence without a terminal outcome | +| `iis-supplemental` | Admin Service success plus IIS context | IIS cannot create or raise transaction confidence | +| `privacy-redaction` | distinct Provider/Admin Service successes | same request-like ID stays split by layer/endpoint; raw synthetic sensitive shapes are absent publicly | +| `rotation-boundary` | no transaction | split fragments and unknown version cannot create an exact key | +| `incomplete` | Admin Service incomplete | bounded Admin Service follow-up only | + +## Contract boundaries + +- Exact request identity requires a profile-validated request ID, safe + operation handle, endpoint ID, layer, and compatible topology. +- Endpoint paths, caller identities, query text, URL parameters, + authorization values, and same-minute timestamps are not key material. +- High confidence requires a complete captured artifact, usable timestamp + provenance, explicit terminal evidence, exact topology, and no coverage gap. +- Terminal evidence is admitted only at the source-specific `recordOutcome` + phase. A later exact-key record cannot be omitted from the transaction. +- `captured` artifacts cannot report an applied collection limit; capped + artifacts must report the applied byte limit and cannot support high + confidence. +- Physical relative paths are bound to source ID, endpoint, basename, and + rotation kind. Unknown source versions use a closed public version grammar, + and exact topology never accepts an empty endpoint. +- Missing, invalid-offset, unknown-version, partial-rotation, unsupported, and + supplemental evidence remain coverage or source-local states. +- Every public transaction observation cites one normalized logical CCM + record and cannot reuse or cross a Provider/Admin Service layer. +- Public expected output contains no cross-side causal claim. Any future + correlation remains outside #332 and must satisfy #333 contracts. + +The manifest/expected JSON is preparation-only with reviewed #318 and #335 +dependencies available. It must not be treated as an implemented native +manifest. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-access-denied/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-access-denied/expected.json new file mode 100644 index 000000000..32f9c6faa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-access-denied/expected.json @@ -0,0 +1,88 @@ +{ + "artifactRequests": [ + { + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "request": { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + }, + "sourceVersion": null, + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "coverage": [ + { + "artifactId": "coverage-admin-access-denied", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": null, + "state": "accessDenied", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "coverage-admin-access-denied", + "coverage": "accessDenied", + "role": "adminService" + } + ], + "evidence": [], + "findingId": "provider-admin-coverage:coverage-admin-access-denied", + "nextArtifacts": [ + { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + } + ], + "phase": "providerAndAdminService", + "role": "adminService", + "severity": "Warning", + "summary": "Admin Service cannot be evaluated because its scoped source is not a complete capture.", + "terminalEvidence": [], + "title": "Admin Service evidence unavailable" + }, + "lastSuccessfulPhase": null, + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "sourceId": "server-admin-service", + "sourceVersion": null, + "subjectId": "coverage-admin-access-denied", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "unknownVersion" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-access-denied/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-access-denied/manifest.json new file mode 100644 index 000000000..edec0a0f9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-access-denied/manifest.json @@ -0,0 +1,44 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService" + ] + }, + "artifacts": [ + { + "artifactId": "coverage-admin-access-denied", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "accessDenied", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 0, + "collectionDetail": "synthetic permission denial" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log new file mode 100644 index 000000000..3036259b6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/expected.json new file mode 100644 index 000000000..dc6b5d73e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/expected.json @@ -0,0 +1,173 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "admin-auth-current", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "admin-auth-current", + "entryId": "admin-auth-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "admin-auth-current", + "entryId": "admin-auth-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "admin-auth-current", + "entryId": "admin-auth-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "findingId": "provider-admin-finding:cmtraceopen.finding.sha256.v1:7048cb42fdd9ce196771736db023d9cb9f8d32d49610156a612bbe7ff8be51cb", + "nextArtifacts": [], + "phase": "providerAndAdminService", + "role": "adminService", + "severity": "Error", + "summary": "Admin Service recorded an explicit terminal operation failure.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "admin-auth-current", + "entryId": "admin-auth-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + } + ], + "title": "Admin Service confirmed failure" + }, + "lastSuccessfulPhase": "receive", + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "subjectId": "adminService:cmtraceopen.request.sha256.v1:aaed0dd34503443365ef8be494a344c589a37d241e8d05370b51a399beee2b59:cmtraceopen.operation.sha256.v1:36819ae35e44be7bafa2ba69cc69e50b53286f81dd0cbce4f2bc4974150fc7c2:synthetic:host:admin-service-01:synthetic:subject:admin-service-01", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:admin-service-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:36819ae35e44be7bafa2ba69cc69e50b53286f81dd0cbce4f2bc4974150fc7c2", + "producerHostHandle": "synthetic:host:admin-service-01", + "requestHandle": "cmtraceopen.request.sha256.v1:aaed0dd34503443365ef8be494a344c589a37d241e8d05370b51a399beee2b59" + }, + "lastSuccessfulPhase": "receive", + "layer": "adminService", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-auth-current", + "entryId": "admin-auth-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "admin-auth-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "failed", + "evidence": [ + { + "artifactId": "admin-auth-current", + "entryId": "admin-auth-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "admin-auth-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "failed", + "evidence": [ + { + "artifactId": "admin-auth-current", + "entryId": "admin-auth-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "admin-auth-current:3-3-03", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "adminService", + "publicSummary": "Admin Service recorded an explicit terminal operation failure.", + "sourceVersion": "5.00.TEST", + "state": "failed", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "adminService:cmtraceopen.request.sha256.v1:aaed0dd34503443365ef8be494a344c589a37d241e8d05370b51a399beee2b59:cmtraceopen.operation.sha256.v1:36819ae35e44be7bafa2ba69cc69e50b53286f81dd0cbce4f2bc4974150fc7c2:synthetic:host:admin-service-01:synthetic:subject:admin-service-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/manifest.json new file mode 100644 index 000000000..65b15153a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService" + ] + }, + "artifacts": [ + { + "artifactId": "admin-auth-current", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 1248, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log new file mode 100644 index 000000000..2191373be --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/expected.json new file mode 100644 index 000000000..f15893efa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/expected.json @@ -0,0 +1,213 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "admin-backend-current", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "findingId": "provider-admin-finding:cmtraceopen.finding.sha256.v1:ed811b8050ba1f029dba64ff7caa00623603849848ad2c72ef68b54f80a3c60e", + "nextArtifacts": [], + "phase": "providerAndAdminService", + "role": "adminService", + "severity": "Error", + "summary": "Admin Service recorded an explicit terminal operation failure.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + } + ], + "title": "Admin Service confirmed failure" + }, + "lastSuccessfulPhase": "route", + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "subjectId": "adminService:cmtraceopen.request.sha256.v1:c83b03e700efb70eeb3a27cf47f69f9e54e59375f79b1d8b2896f4d89cfc32ab:cmtraceopen.operation.sha256.v1:526ee4e602f7917ad02c5a48c2c4406d02a9f6a19d27173de039e74896de2ec2:synthetic:host:admin-service-01:synthetic:subject:admin-service-01", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:admin-service-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:526ee4e602f7917ad02c5a48c2c4406d02a9f6a19d27173de039e74896de2ec2", + "producerHostHandle": "synthetic:host:admin-service-01", + "requestHandle": "cmtraceopen.request.sha256.v1:c83b03e700efb70eeb3a27cf47f69f9e54e59375f79b1d8b2896f4d89cfc32ab" + }, + "lastSuccessfulPhase": "route", + "layer": "adminService", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "admin-backend-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "admin-backend-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "admin-backend-current:3-3-03", + "phase": "route", + "terminal": false + }, + { + "disposition": "failed", + "evidence": [ + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "observationId": "admin-backend-current:4-4-04", + "phase": "executeBackendOperation", + "terminal": false + }, + { + "disposition": "failed", + "evidence": [ + { + "artifactId": "admin-backend-current", + "entryId": "admin-backend-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "observationId": "admin-backend-current:5-5-05", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "adminService", + "publicSummary": "Admin Service recorded an explicit terminal operation failure.", + "sourceVersion": "5.00.TEST", + "state": "failed", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "adminService:cmtraceopen.request.sha256.v1:c83b03e700efb70eeb3a27cf47f69f9e54e59375f79b1d8b2896f4d89cfc32ab:cmtraceopen.operation.sha256.v1:526ee4e602f7917ad02c5a48c2c4406d02a9f6a19d27173de039e74896de2ec2:synthetic:host:admin-service-01:synthetic:subject:admin-service-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/manifest.json new file mode 100644 index 000000000..f8c50c95b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService" + ] + }, + "artifacts": [ + { + "artifactId": "admin-backend-current", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2099, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log new file mode 100644 index 000000000..6aaf4928a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log @@ -0,0 +1 @@ +SYNTHETIC MALFORMED ADMIN SERVICE RECORD WITHOUT CCM DELIMITERS diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/expected.json new file mode 100644 index 000000000..e101c8b7b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/expected.json @@ -0,0 +1,88 @@ +{ + "artifactRequests": [ + { + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "request": { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + }, + "sourceVersion": "5.00.TEST", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "coverage": [ + { + "artifactId": "coverage-admin-parse-failed", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "parseFailed", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "coverage-admin-parse-failed", + "coverage": "parseFailed", + "role": "adminService" + } + ], + "evidence": [], + "findingId": "provider-admin-coverage:coverage-admin-parse-failed", + "nextArtifacts": [ + { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + } + ], + "phase": "providerAndAdminService", + "role": "adminService", + "severity": "Warning", + "summary": "Admin Service cannot be evaluated because its scoped source is not a complete capture.", + "terminalEvidence": [], + "title": "Admin Service evidence unavailable" + }, + "lastSuccessfulPhase": null, + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "subjectId": "coverage-admin-parse-failed", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/manifest.json new file mode 100644 index 000000000..09742cf92 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService" + ] + }, + "artifacts": [ + { + "artifactId": "coverage-admin-parse-failed", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "parseFailed", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 64, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-skipped/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-skipped/expected.json new file mode 100644 index 000000000..5f9deae25 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-skipped/expected.json @@ -0,0 +1,88 @@ +{ + "artifactRequests": [ + { + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "request": { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + }, + "sourceVersion": null, + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "coverage": [ + { + "artifactId": "coverage-admin-skipped", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": null, + "state": "skipped", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "coverage-admin-skipped", + "coverage": "skipped", + "role": "adminService" + } + ], + "evidence": [], + "findingId": "provider-admin-coverage:coverage-admin-skipped", + "nextArtifacts": [ + { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + } + ], + "phase": "providerAndAdminService", + "role": "adminService", + "severity": "Warning", + "summary": "Admin Service cannot be evaluated because its scoped source is not a complete capture.", + "terminalEvidence": [], + "title": "Admin Service evidence unavailable" + }, + "lastSuccessfulPhase": null, + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "sourceId": "server-admin-service", + "sourceVersion": null, + "subjectId": "coverage-admin-skipped", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "unknownVersion" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-skipped/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-skipped/manifest.json new file mode 100644 index 000000000..2b0a1cb5d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-skipped/manifest.json @@ -0,0 +1,44 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService" + ] + }, + "artifacts": [ + { + "artifactId": "coverage-admin-skipped", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "notRequested", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "skipped", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 0, + "skipReason": "optional supplemental source not requested" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log new file mode 100644 index 000000000..1fde5c2d8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/expected.json new file mode 100644 index 000000000..3b9218db4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/expected.json @@ -0,0 +1,161 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "admin-success-current", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "crossSideCausalClaims": [], + "findings": [], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:admin-service-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:2d6d688f3314b2323ce53f3ead467801d78f03ad9de815273ae1bfeaf2f7fdf1", + "producerHostHandle": "synthetic:host:admin-service-01", + "requestHandle": "cmtraceopen.request.sha256.v1:eff6cfda08449d552d2482cc2735ed08e2e4b65d6e20a90548ff4c95824e565a" + }, + "lastSuccessfulPhase": "recordOutcome", + "layer": "adminService", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-success-current", + "entryId": "admin-success-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "admin-success-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-success-current", + "entryId": "admin-success-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "admin-success-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-success-current", + "entryId": "admin-success-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "admin-success-current:3-3-03", + "phase": "route", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-success-current", + "entryId": "admin-success-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "observationId": "admin-success-current:4-4-04", + "phase": "executeBackendOperation", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-success-current", + "entryId": "admin-success-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "observationId": "admin-success-current:5-5-05", + "phase": "respond", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-success-current", + "entryId": "admin-success-current:6-6", + "lineEnd": 6, + "lineStart": 6 + } + ], + "observationId": "admin-success-current:6-6-06", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "adminService", + "publicSummary": "Admin Service operation completed with explicit terminal evidence.", + "sourceVersion": "5.00.TEST", + "state": "succeeded", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "adminService:cmtraceopen.request.sha256.v1:eff6cfda08449d552d2482cc2735ed08e2e4b65d6e20a90548ff4c95824e565a:cmtraceopen.operation.sha256.v1:2d6d688f3314b2323ce53f3ead467801d78f03ad9de815273ae1bfeaf2f7fdf1:synthetic:host:admin-service-01:synthetic:subject:admin-service-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/manifest.json new file mode 100644 index 000000000..e6ae18097 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService" + ] + }, + "artifacts": [ + { + "artifactId": "admin-success-current", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2501, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log new file mode 100644 index 000000000..e8524100e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/expected.json new file mode 100644 index 000000000..eba08c0b8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/expected.json @@ -0,0 +1,195 @@ +{ + "artifactRequests": [ + { + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "request": { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + }, + "sourceVersion": "5.00.TEST", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "coverage": [ + { + "artifactId": "blocked-deferred-admin-current", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "blockedOrDeferred", + "confidence": "moderate", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "blocked-deferred-admin-current", + "entryId": "blocked-deferred-admin-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "blocked-deferred-admin-current", + "entryId": "blocked-deferred-admin-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "blocked-deferred-admin-current", + "entryId": "blocked-deferred-admin-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "findingId": "provider-admin-finding:cmtraceopen.finding.sha256.v1:1daee4807bef24f65f11588e50c2917eb53be2645485bc2da695e59165fa0ed2", + "nextArtifacts": [ + { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + } + ], + "phase": "providerAndAdminService", + "role": "adminService", + "severity": "Warning", + "summary": "Admin Service evidence records a blocked or deferred request without a terminal outcome.", + "terminalEvidence": [], + "title": "Admin Service blocked or deferred" + }, + "lastSuccessfulPhase": "authenticateOrAuthorize", + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "subjectId": "adminService:cmtraceopen.request.sha256.v1:c90815886d04b162dd7a2e2393ee97fbbbc1c5ccb166116ec825e167f0bb094b:cmtraceopen.operation.sha256.v1:845ac95236bc882dceb085e4c8f22b2080fa2f1731b7e30e3a63a5c22627f305:synthetic:host:admin-service-01:synthetic:subject:admin-service-01", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "blockedOrDeferred", + "confidence": "moderate", + "confidenceCeiling": "moderate", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:admin-service-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:845ac95236bc882dceb085e4c8f22b2080fa2f1731b7e30e3a63a5c22627f305", + "producerHostHandle": "synthetic:host:admin-service-01", + "requestHandle": "cmtraceopen.request.sha256.v1:c90815886d04b162dd7a2e2393ee97fbbbc1c5ccb166116ec825e167f0bb094b" + }, + "lastSuccessfulPhase": "authenticateOrAuthorize", + "layer": "adminService", + "nextArtifactRequests": [ + { + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "request": { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + }, + "sourceVersion": "5.00.TEST", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "blocked-deferred-admin-current", + "entryId": "blocked-deferred-admin-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "blocked-deferred-admin-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "blocked-deferred-admin-current", + "entryId": "blocked-deferred-admin-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "blocked-deferred-admin-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "pending", + "evidence": [ + { + "artifactId": "blocked-deferred-admin-current", + "entryId": "blocked-deferred-admin-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "blocked-deferred-admin-current:3-3-03", + "phase": "route", + "terminal": false + } + ], + "producerRole": "adminService", + "publicSummary": "Admin Service evidence records a blocked or deferred request without a terminal outcome.", + "sourceVersion": "5.00.TEST", + "state": "blockedOrDeferred", + "terminalEvidence": false, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "adminService:cmtraceopen.request.sha256.v1:c90815886d04b162dd7a2e2393ee97fbbbc1c5ccb166116ec825e167f0bb094b:cmtraceopen.operation.sha256.v1:845ac95236bc882dceb085e4c8f22b2080fa2f1731b7e30e3a63a5c22627f305:synthetic:host:admin-service-01:synthetic:subject:admin-service-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/manifest.json new file mode 100644 index 000000000..cc61936af --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService" + ] + }, + "artifacts": [ + { + "artifactId": "blocked-deferred-admin-current", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 1257, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log new file mode 100644 index 000000000..09f7e242a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/expected.json new file mode 100644 index 000000000..4a6941b8a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/expected.json @@ -0,0 +1,233 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "contradictory-provider-current", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "contradictoryEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:6-6", + "lineEnd": 6, + "lineStart": 6 + } + ], + "findingId": "provider-admin-finding:cmtraceopen.finding.sha256.v1:90053a39747d875b371d60ecddc5d814d7f83b83d8b2267db0229615824a50eb", + "nextArtifacts": [], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Warning", + "summary": "Provider records mutually exclusive terminal outcomes.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:6-6", + "lineEnd": 6, + "lineStart": 6 + } + } + ], + "title": "Provider contradictory evidence" + }, + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "subjectId": "provider:cmtraceopen.request.sha256.v1:e15a4cfebaf90cdd88c82b9b3aac5d8d046b083b42b751bbbad8aad4de4c0712:cmtraceopen.operation.sha256.v1:5c85529b2d5209d5fab67a4624f312b55977b53339770b99628ef6d00f339a3a:synthetic:host:provider-01:synthetic:subject:provider-01", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "contradictoryEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:provider-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:5c85529b2d5209d5fab67a4624f312b55977b53339770b99628ef6d00f339a3a", + "producerHostHandle": "synthetic:host:provider-01", + "requestHandle": "cmtraceopen.request.sha256.v1:e15a4cfebaf90cdd88c82b9b3aac5d8d046b083b42b751bbbad8aad4de4c0712" + }, + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "contradictory-provider-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "contradictory-provider-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "contradictory-provider-current:3-3-03", + "phase": "executeProviderOperation", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "observationId": "contradictory-provider-current:4-4-04", + "phase": "respond", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "observationId": "contradictory-provider-current:5-5-05", + "phase": "recordOutcome", + "terminal": true + }, + { + "disposition": "failed", + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "entryId": "contradictory-provider-current:6-6", + "lineEnd": 6, + "lineStart": 6 + } + ], + "observationId": "contradictory-provider-current:6-6-06", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "provider", + "publicSummary": "Provider records mutually exclusive terminal outcomes.", + "sourceVersion": "5.00.TEST", + "state": "contradictory", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "provider:cmtraceopen.request.sha256.v1:e15a4cfebaf90cdd88c82b9b3aac5d8d046b083b42b751bbbad8aad4de4c0712:cmtraceopen.operation.sha256.v1:5c85529b2d5209d5fab67a4624f312b55977b53339770b99628ef6d00f339a3a:synthetic:host:provider-01:synthetic:subject:provider-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/manifest.json new file mode 100644 index 000000000..26a1a16b7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "contradictory-provider-current", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2416, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/sccm/server/admin-service/server-admin-service-iis/subject-admin-service/current/u_ex_synthetic.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/sccm/server/admin-service/server-admin-service-iis/subject-admin-service/current/u_ex_synthetic.log new file mode 100644 index 000000000..0af8dc403 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/sccm/server/admin-service/server-admin-service-iis/subject-admin-service/current/u_ex_synthetic.log @@ -0,0 +1,2 @@ +#Software: SYNTHETIC IIS W3C +2026-07-30 22:30:02 safe:server:lab-provider-01 POST /synthetic-admin-endpoint 200 request=88888888-8888-8888-8888-888888888888 diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log new file mode 100644 index 000000000..f48ff0635 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/expected.json new file mode 100644 index 000000000..6f6fe200e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/expected.json @@ -0,0 +1,179 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "admin-iis-current", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + }, + { + "artifactId": "iis-supplemental-current", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service-iis", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "crossSideCausalClaims": [], + "findings": [], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [ + { + "artifactIds": [ + "iis-supplemental-current" + ], + "correlationEligible": false, + "kind": "supplementalOnly", + "observationId": "iis-supplemental-current-supplemental" + } + ], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:admin-service-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:52e7facc0b9f7e93cc97bb8b6163b90cd97a62cbfe354404ade4e49d28dbcfd5", + "producerHostHandle": "synthetic:host:admin-service-01", + "requestHandle": "cmtraceopen.request.sha256.v1:ef09fc0c6d411c6d2a544f212cff0f369028a8b5891aecf94c976180f974d144" + }, + "lastSuccessfulPhase": "recordOutcome", + "layer": "adminService", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-iis-current", + "entryId": "admin-iis-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "admin-iis-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-iis-current", + "entryId": "admin-iis-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "admin-iis-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-iis-current", + "entryId": "admin-iis-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "admin-iis-current:3-3-03", + "phase": "route", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-iis-current", + "entryId": "admin-iis-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "observationId": "admin-iis-current:4-4-04", + "phase": "executeBackendOperation", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-iis-current", + "entryId": "admin-iis-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "observationId": "admin-iis-current:5-5-05", + "phase": "respond", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "admin-iis-current", + "entryId": "admin-iis-current:6-6", + "lineEnd": 6, + "lineStart": 6 + } + ], + "observationId": "admin-iis-current:6-6-06", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "adminService", + "publicSummary": "Admin Service operation completed with explicit terminal evidence.", + "sourceVersion": "5.00.TEST", + "state": "succeeded", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "adminService:cmtraceopen.request.sha256.v1:ef09fc0c6d411c6d2a544f212cff0f369028a8b5891aecf94c976180f974d144:cmtraceopen.operation.sha256.v1:52e7facc0b9f7e93cc97bb8b6163b90cd97a62cbfe354404ade4e49d28dbcfd5:synthetic:host:admin-service-01:synthetic:subject:admin-service-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/manifest.json new file mode 100644 index 000000000..0f230ea73 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/manifest.json @@ -0,0 +1,81 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService" + ] + }, + "artifacts": [ + { + "artifactId": "admin-iis-current", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2495, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log" + }, + { + "artifactId": "iis-supplemental-current", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service-iis", + "sourceKind": "iisW3c", + "originalPath": "REDACTED_ADMIN_SERVICE_IIS_ROOT", + "originalBasename": "u_ex_synthetic.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-iis" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-iis" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 157, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/admin-service/server-admin-service-iis/subject-admin-service/current/u_ex_synthetic.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log new file mode 100644 index 000000000..3a7a62e2d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/expected.json new file mode 100644 index 000000000..9d13617a7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/expected.json @@ -0,0 +1,155 @@ +{ + "artifactRequests": [ + { + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "request": { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + }, + "sourceVersion": "5.00.TEST", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "coverage": [ + { + "artifactId": "incomplete-admin-current", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "symptom", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "incomplete-admin-current", + "entryId": "incomplete-admin-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "findingId": "provider-admin-finding:cmtraceopen.finding.sha256.v1:b6bc5a43cb150c072701da1e1c4e8ad0a39a0921d4dd10e46528c49390a50ea7", + "nextArtifacts": [ + { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + } + ], + "phase": "providerAndAdminService", + "role": "adminService", + "severity": "Warning", + "summary": "Admin Service evidence stops before a valid explicit terminal outcome.", + "terminalEvidence": [], + "title": "Admin Service insufficient evidence" + }, + "lastSuccessfulPhase": "receive", + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "subjectId": "adminService:cmtraceopen.request.sha256.v1:97a6cea7f46fc765262ee2f57d2e7e016177284ba8374f913746cc43a87621ca:cmtraceopen.operation.sha256.v1:905a8c2e653f8e07d78f0f2dfeab1d71fe77fff7cb18f9f26cf7894b9a7f5fce:synthetic:host:admin-service-01:synthetic:subject:admin-service-01", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "insufficientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:admin-service-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:905a8c2e653f8e07d78f0f2dfeab1d71fe77fff7cb18f9f26cf7894b9a7f5fce", + "producerHostHandle": "synthetic:host:admin-service-01", + "requestHandle": "cmtraceopen.request.sha256.v1:97a6cea7f46fc765262ee2f57d2e7e016177284ba8374f913746cc43a87621ca" + }, + "lastSuccessfulPhase": "receive", + "layer": "adminService", + "nextArtifactRequests": [ + { + "layer": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "request": { + "logicalId": "adminService", + "reason": "Collect the complete AdminService.log file.", + "role": "adminService" + }, + "sourceVersion": "5.00.TEST", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + } + ], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "incomplete-admin-current", + "entryId": "incomplete-admin-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "incomplete-admin-current:1-1-01", + "phase": "receive", + "terminal": false + } + ], + "producerRole": "adminService", + "publicSummary": "Admin Service evidence stops before a valid explicit terminal outcome.", + "sourceVersion": "5.00.TEST", + "state": "incomplete", + "terminalEvidence": false, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "adminService:cmtraceopen.request.sha256.v1:97a6cea7f46fc765262ee2f57d2e7e016177284ba8374f913746cc43a87621ca:cmtraceopen.operation.sha256.v1:905a8c2e653f8e07d78f0f2dfeab1d71fe77fff7cb18f9f26cf7894b9a7f5fce:synthetic:host:admin-service-01:synthetic:subject:admin-service-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/manifest.json new file mode 100644 index 000000000..340f43d28 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService" + ] + }, + "artifacts": [ + { + "artifactId": "incomplete-admin-current", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 417, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log new file mode 100644 index 000000000..f49122ede --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log new file mode 100644 index 000000000..204009e3f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/expected.json new file mode 100644 index 000000000..839e71226 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/expected.json @@ -0,0 +1,311 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "privacy-admin-current", + "producerHostHandle": "synthetic:host:admin-service-01", + "producerRole": "adminService", + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" + }, + { + "artifactId": "privacy-provider-current", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + }, + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "layer": "adminService", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [ + { + "artifactIds": [ + "privacy-admin-current" + ], + "correlationEligible": false, + "kind": "privacyRedacted", + "observationId": "privacy-admin-current-privacy" + }, + { + "artifactIds": [ + "privacy-provider-current" + ], + "correlationEligible": false, + "kind": "privacyRedacted", + "observationId": "privacy-provider-current-privacy" + } + ], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:admin-service-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "admin-service-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "adminService" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:cca09a76facbad65ba2d339785030164782e745b293c0ffd7ab3f3606398e849", + "producerHostHandle": "synthetic:host:admin-service-01", + "requestHandle": "cmtraceopen.request.sha256.v1:c1bf4eb3ae2066274cdc69dc3e504be5d662971573db81b8b1c49076c7c64070" + }, + "lastSuccessfulPhase": "recordOutcome", + "layer": "adminService", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-admin-current", + "entryId": "privacy-admin-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "privacy-admin-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-admin-current", + "entryId": "privacy-admin-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "privacy-admin-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-admin-current", + "entryId": "privacy-admin-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "privacy-admin-current:3-3-03", + "phase": "route", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-admin-current", + "entryId": "privacy-admin-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "observationId": "privacy-admin-current:4-4-04", + "phase": "executeBackendOperation", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-admin-current", + "entryId": "privacy-admin-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "observationId": "privacy-admin-current:5-5-05", + "phase": "respond", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-admin-current", + "entryId": "privacy-admin-current:6-6", + "lineEnd": 6, + "lineStart": 6 + } + ], + "observationId": "privacy-admin-current:6-6-06", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "adminService", + "publicSummary": "Admin Service operation completed with explicit terminal evidence.", + "sourceVersion": "5.00.TEST", + "state": "succeeded", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "adminService:cmtraceopen.request.sha256.v1:c1bf4eb3ae2066274cdc69dc3e504be5d662971573db81b8b1c49076c7c64070:cmtraceopen.operation.sha256.v1:cca09a76facbad65ba2d339785030164782e745b293c0ffd7ab3f3606398e849:synthetic:host:admin-service-01:synthetic:subject:admin-service-01" + }, + { + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:provider-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:f7d06e2b60022b880d60cf2c32ed17af1c034dc976092b0974e746d30244fb82", + "producerHostHandle": "synthetic:host:provider-01", + "requestHandle": "cmtraceopen.request.sha256.v1:c1bf4eb3ae2066274cdc69dc3e504be5d662971573db81b8b1c49076c7c64070" + }, + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-provider-current", + "entryId": "privacy-provider-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "privacy-provider-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-provider-current", + "entryId": "privacy-provider-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "privacy-provider-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-provider-current", + "entryId": "privacy-provider-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "privacy-provider-current:3-3-03", + "phase": "executeProviderOperation", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-provider-current", + "entryId": "privacy-provider-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "observationId": "privacy-provider-current:4-4-04", + "phase": "respond", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "privacy-provider-current", + "entryId": "privacy-provider-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "observationId": "privacy-provider-current:5-5-05", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "provider", + "publicSummary": "Provider operation completed with explicit terminal evidence.", + "sourceVersion": "5.00.TEST", + "state": "succeeded", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "provider:cmtraceopen.request.sha256.v1:c1bf4eb3ae2066274cdc69dc3e504be5d662971573db81b8b1c49076c7c64070:cmtraceopen.operation.sha256.v1:f7d06e2b60022b880d60cf2c32ed17af1c034dc976092b0974e746d30244fb82:synthetic:host:provider-01:synthetic:subject:provider-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/manifest.json new file mode 100644 index 000000000..46a4da9e1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/manifest.json @@ -0,0 +1,82 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "adminService", + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "privacy-admin-current", + "producerRole": "adminService", + "producerHostHandle": "synthetic:host:admin-service-01", + "workflowSubject": { + "role": "adminService", + "instanceHandle": "synthetic:subject:admin-service-01" + }, + "sourceId": "server-admin-service", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", + "originalBasename": "AdminService.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "admin-service-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2603, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/admin-service/server-admin-service/subject-admin-service/current/AdminService.log" + }, + { + "artifactId": "privacy-provider-current", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2175, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log new file mode 100644 index 000000000..3e482b373 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/expected.json new file mode 100644 index 000000000..29f827351 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/expected.json @@ -0,0 +1,173 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "provider-authz-current", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "provider-authz-current", + "entryId": "provider-authz-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "provider-authz-current", + "entryId": "provider-authz-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "provider-authz-current", + "entryId": "provider-authz-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "findingId": "provider-admin-finding:cmtraceopen.finding.sha256.v1:e1af0eb5f901188dbce4e55b928bcfcbb4d4c8d3e3643e7835bd6f67d6748fa6", + "nextArtifacts": [], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Error", + "summary": "Provider recorded an explicit terminal operation failure.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "provider-authz-current", + "entryId": "provider-authz-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + } + ], + "title": "Provider confirmed failure" + }, + "lastSuccessfulPhase": "receive", + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "subjectId": "provider:cmtraceopen.request.sha256.v1:77c30000cce4b8fc20b350b127be93e98a151350148532a13a87be969f87cf41:cmtraceopen.operation.sha256.v1:fcdca2da9eaae5be46be853e5b18b78ce63bca3aea9f92597badfc3a2f8143b2:synthetic:host:provider-01:synthetic:subject:provider-01", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:provider-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:fcdca2da9eaae5be46be853e5b18b78ce63bca3aea9f92597badfc3a2f8143b2", + "producerHostHandle": "synthetic:host:provider-01", + "requestHandle": "cmtraceopen.request.sha256.v1:77c30000cce4b8fc20b350b127be93e98a151350148532a13a87be969f87cf41" + }, + "lastSuccessfulPhase": "receive", + "layer": "provider", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-authz-current", + "entryId": "provider-authz-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "provider-authz-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "failed", + "evidence": [ + { + "artifactId": "provider-authz-current", + "entryId": "provider-authz-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "provider-authz-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "failed", + "evidence": [ + { + "artifactId": "provider-authz-current", + "entryId": "provider-authz-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "provider-authz-current:3-3-03", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "provider", + "publicSummary": "Provider recorded an explicit terminal operation failure.", + "sourceVersion": "5.00.TEST", + "state": "failed", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "provider:cmtraceopen.request.sha256.v1:77c30000cce4b8fc20b350b127be93e98a151350148532a13a87be969f87cf41:cmtraceopen.operation.sha256.v1:fcdca2da9eaae5be46be853e5b18b78ce63bca3aea9f92597badfc3a2f8143b2:synthetic:host:provider-01:synthetic:subject:provider-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/manifest.json new file mode 100644 index 000000000..60ccf3d9e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "provider-authz-current", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 1203, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log new file mode 100644 index 000000000..e0e41a6c8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/expected.json new file mode 100644 index 000000000..e72247fa9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/expected.json @@ -0,0 +1,193 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "provider-query-current", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "confirmedFailure", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "provider-query-current", + "entryId": "provider-query-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "provider-query-current", + "entryId": "provider-query-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "provider-query-current", + "entryId": "provider-query-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "provider-query-current", + "entryId": "provider-query-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "findingId": "provider-admin-finding:cmtraceopen.finding.sha256.v1:0b4d32911441b1129ffb0ccc83596503ed18bfff0c884b79598a705cc24354d3", + "nextArtifacts": [], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Error", + "summary": "Provider recorded an explicit terminal operation failure.", + "terminalEvidence": [ + { + "kind": "observedFailure", + "reference": { + "artifactId": "provider-query-current", + "entryId": "provider-query-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + } + ], + "title": "Provider confirmed failure" + }, + "lastSuccessfulPhase": "authenticateOrAuthorize", + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "subjectId": "provider:cmtraceopen.request.sha256.v1:618f1a429a91316d819f7a982e0efa06caa436f9d980f6fd2734650bf92fb8a7:cmtraceopen.operation.sha256.v1:f8531c63ceea45b588fb4ae3eb63e391a4f8bf6655d93cc2d66d4d4adc53935b:synthetic:host:provider-01:synthetic:subject:provider-01", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:provider-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:f8531c63ceea45b588fb4ae3eb63e391a4f8bf6655d93cc2d66d4d4adc53935b", + "producerHostHandle": "synthetic:host:provider-01", + "requestHandle": "cmtraceopen.request.sha256.v1:618f1a429a91316d819f7a982e0efa06caa436f9d980f6fd2734650bf92fb8a7" + }, + "lastSuccessfulPhase": "authenticateOrAuthorize", + "layer": "provider", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-query-current", + "entryId": "provider-query-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "provider-query-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-query-current", + "entryId": "provider-query-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "provider-query-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "failed", + "evidence": [ + { + "artifactId": "provider-query-current", + "entryId": "provider-query-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "provider-query-current:3-3-03", + "phase": "executeProviderOperation", + "terminal": false + }, + { + "disposition": "failed", + "evidence": [ + { + "artifactId": "provider-query-current", + "entryId": "provider-query-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "observationId": "provider-query-current:4-4-04", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "provider", + "publicSummary": "Provider recorded an explicit terminal operation failure.", + "sourceVersion": "5.00.TEST", + "state": "failed", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "provider:cmtraceopen.request.sha256.v1:618f1a429a91316d819f7a982e0efa06caa436f9d980f6fd2734650bf92fb8a7:cmtraceopen.operation.sha256.v1:f8531c63ceea45b588fb4ae3eb63e391a4f8bf6655d93cc2d66d4d4adc53935b:synthetic:host:provider-01:synthetic:subject:provider-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/manifest.json new file mode 100644 index 000000000..a174d73bb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "provider-query-current", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 1612, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log new file mode 100644 index 000000000..d65859b52 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/expected.json new file mode 100644 index 000000000..0af038662 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/expected.json @@ -0,0 +1,223 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "provider-retry-current", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "recovered", + "confidence": "high", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:3-3", + "lineEnd": 3, + "lineStart": 3 + }, + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:4-4", + "lineEnd": 4, + "lineStart": 4 + }, + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:5-5", + "lineEnd": 5, + "lineStart": 5 + }, + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:6-6", + "lineEnd": 6, + "lineStart": 6 + } + ], + "findingId": "provider-admin-finding:cmtraceopen.finding.sha256.v1:0ba6ec760743d0091fa3a679aaff3c40e01009da41283646edebe2acbca49bb7", + "nextArtifacts": [], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Success", + "summary": "Provider operation recovered after an explicit retryable failure.", + "terminalEvidence": [], + "title": "Provider recovered" + }, + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "subjectId": "provider:cmtraceopen.request.sha256.v1:56eabbf71e1d01bf5bd805b2946dbf57d241c29f841155a98b372031c0778890:cmtraceopen.operation.sha256.v1:4a9ec7dbcf07d5b0bf06307983a0863ffaec89cd51e6d265342e9a733ccf28ef:synthetic:host:provider-01:synthetic:subject:provider-01", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "recovered", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:provider-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:4a9ec7dbcf07d5b0bf06307983a0863ffaec89cd51e6d265342e9a733ccf28ef", + "producerHostHandle": "synthetic:host:provider-01", + "requestHandle": "cmtraceopen.request.sha256.v1:56eabbf71e1d01bf5bd805b2946dbf57d241c29f841155a98b372031c0778890" + }, + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "provider-retry-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "provider-retry-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "retryableFailure", + "evidence": [ + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "provider-retry-current:3-3-03", + "phase": "executeProviderOperation", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "observationId": "provider-retry-current:4-4-04", + "phase": "executeProviderOperation", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "observationId": "provider-retry-current:5-5-05", + "phase": "respond", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-retry-current", + "entryId": "provider-retry-current:6-6", + "lineEnd": 6, + "lineStart": 6 + } + ], + "observationId": "provider-retry-current:6-6-06", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "provider", + "publicSummary": "Provider operation recovered after an explicit retryable failure.", + "sourceVersion": "5.00.TEST", + "state": "recovered", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "provider:cmtraceopen.request.sha256.v1:56eabbf71e1d01bf5bd805b2946dbf57d241c29f841155a98b372031c0778890:cmtraceopen.operation.sha256.v1:4a9ec7dbcf07d5b0bf06307983a0863ffaec89cd51e6d265342e9a733ccf28ef:synthetic:host:provider-01:synthetic:subject:provider-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/manifest.json new file mode 100644 index 000000000..3b5c5d42c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "provider-retry-current", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2444, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/expected.json new file mode 100644 index 000000000..a5a70992c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/expected.json @@ -0,0 +1,88 @@ +{ + "artifactRequests": [ + { + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "request": { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + }, + "sourceVersion": null, + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "coverage": [ + { + "artifactId": "coverage-provider-absent", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": null, + "state": "absent", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "coverage-provider-absent", + "coverage": "absent", + "role": "provider" + } + ], + "evidence": [], + "findingId": "provider-admin-coverage:coverage-provider-absent", + "nextArtifacts": [ + { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + } + ], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Warning", + "summary": "Provider cannot be evaluated because its scoped source is not a complete capture.", + "terminalEvidence": [], + "title": "Provider evidence unavailable" + }, + "lastSuccessfulPhase": null, + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": null, + "subjectId": "coverage-provider-absent", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "unknownVersion" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/manifest.json new file mode 100644 index 000000000..655d6a178 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/manifest.json @@ -0,0 +1,43 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "coverage-provider-absent", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "absent", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 0 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log new file mode 100644 index 000000000..4185d72e3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/expected.json new file mode 100644 index 000000000..120ee7dcf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/expected.json @@ -0,0 +1,97 @@ +{ + "artifactRequests": [ + { + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "request": { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + }, + "sourceVersion": "5.00.TEST", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "coverage": [ + { + "artifactId": "coverage-provider-capped", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "capped", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "coverage-provider-capped", + "coverage": "capped", + "role": "provider" + } + ], + "evidence": [], + "findingId": "provider-admin-coverage:coverage-provider-capped", + "nextArtifacts": [ + { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + } + ], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Warning", + "summary": "Provider cannot be evaluated because its scoped source is not a complete capture.", + "terminalEvidence": [], + "title": "Provider evidence unavailable" + }, + "lastSuccessfulPhase": null, + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "subjectId": "coverage-provider-capped", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [ + { + "artifactIds": [ + "coverage-provider-capped" + ], + "correlationEligible": false, + "kind": "rotationFragment", + "observationId": "coverage-provider-capped-rotation" + } + ], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/manifest.json new file mode 100644 index 000000000..8880e310e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/manifest.json @@ -0,0 +1,52 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "coverage-provider-capped", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "capped", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 398, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 398, + "limitApplied": true + }, + "truncated": true, + "fragmentComplete": false, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-unsupported/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-unsupported/expected.json new file mode 100644 index 000000000..e579fdb4e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-unsupported/expected.json @@ -0,0 +1,88 @@ +{ + "artifactRequests": [ + { + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "request": { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + }, + "sourceVersion": null, + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "coverage": [ + { + "artifactId": "coverage-provider-unsupported", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": null, + "state": "unsupported", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "coverage-provider-unsupported", + "coverage": "unsupported", + "role": "provider" + } + ], + "evidence": [], + "findingId": "provider-admin-coverage:coverage-provider-unsupported", + "nextArtifacts": [ + { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + } + ], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Warning", + "summary": "Provider cannot be evaluated because its scoped source is not a complete capture.", + "terminalEvidence": [], + "title": "Provider evidence unavailable" + }, + "lastSuccessfulPhase": null, + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": null, + "subjectId": "coverage-provider-unsupported", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "unknownVersion" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-unsupported/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-unsupported/manifest.json new file mode 100644 index 000000000..3d44f30e6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-unsupported/manifest.json @@ -0,0 +1,44 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "coverage-provider-unsupported", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "unsupported", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 0, + "unsupportedReason": "no approved server source contract" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log new file mode 100644 index 000000000..829081290 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/expected.json new file mode 100644 index 000000000..45f5219f4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/expected.json @@ -0,0 +1,147 @@ +{ + "artifactRequests": [], + "coverage": [ + { + "artifactId": "provider-success-current", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:provider-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:d7887edd39e359767a86999e1312648ebc17a6c9813b172f03883914bc334a7f", + "producerHostHandle": "synthetic:host:provider-01", + "requestHandle": "cmtraceopen.request.sha256.v1:8e30a49c634a05e538c508d53fe5699ab3e52ce078e500e64a5322c1593f40f4" + }, + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider", + "nextArtifactRequests": [], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-success-current", + "entryId": "provider-success-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "provider-success-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-success-current", + "entryId": "provider-success-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "provider-success-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-success-current", + "entryId": "provider-success-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "provider-success-current:3-3-03", + "phase": "executeProviderOperation", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-success-current", + "entryId": "provider-success-current:4-4", + "lineEnd": 4, + "lineStart": 4 + } + ], + "observationId": "provider-success-current:4-4-04", + "phase": "respond", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-success-current", + "entryId": "provider-success-current:5-5", + "lineEnd": 5, + "lineStart": 5 + } + ], + "observationId": "provider-success-current:5-5-05", + "phase": "recordOutcome", + "terminal": true + } + ], + "producerRole": "provider", + "publicSummary": "Provider operation completed with explicit terminal evidence.", + "sourceVersion": "5.00.TEST", + "state": "succeeded", + "terminalEvidence": true, + "timestampOrdering": "usable", + "topologyCompatibility": "exact", + "transactionId": "provider:cmtraceopen.request.sha256.v1:8e30a49c634a05e538c508d53fe5699ab3e52ce078e500e64a5322c1593f40f4:cmtraceopen.operation.sha256.v1:d7887edd39e359767a86999e1312648ebc17a6c9813b172f03883914bc334a7f:synthetic:host:provider-01:synthetic:subject:provider-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/manifest.json new file mode 100644 index 000000000..8d7b02a70 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "provider-success-current", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2008, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log new file mode 100644 index 000000000..51dd3c609 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/expected.json new file mode 100644 index 000000000..1c6e8ea87 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/expected.json @@ -0,0 +1,195 @@ +{ + "artifactRequests": [ + { + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "request": { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + }, + "sourceVersion": "5.00.TEST", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "coverage": [ + { + "artifactId": "provider-timeout-current", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "symptom", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [], + "evidence": [ + { + "artifactId": "provider-timeout-current", + "entryId": "provider-timeout-current:1-1", + "lineEnd": 1, + "lineStart": 1 + }, + { + "artifactId": "provider-timeout-current", + "entryId": "provider-timeout-current:2-2", + "lineEnd": 2, + "lineStart": 2 + }, + { + "artifactId": "provider-timeout-current", + "entryId": "provider-timeout-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "findingId": "provider-admin-finding:cmtraceopen.finding.sha256.v1:a905773c9e4bccf6937959c95f0a6b7e5f2d7d54466edcdf738787f65be7230e", + "nextArtifacts": [ + { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + } + ], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Warning", + "summary": "Provider evidence is incomplete, contradictory, or not comparably ordered.", + "terminalEvidence": [], + "title": "Provider insufficient evidence" + }, + "lastSuccessfulPhase": null, + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "subjectId": "provider:cmtraceopen.request.sha256.v1:0cc4a44c3fed5ad67d93ffcf74331cf00b27aead47742bdcc118c0a268e52388:cmtraceopen.operation.sha256.v1:81b06444c4b09f92d8fbcf959a2aa771feb38d74be73dec8a8a333797d515fa1:synthetic:host:provider-01:synthetic:subject:provider-01", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "selectedSynthetic" + } + ], + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [ + { + "classification": "insufficientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "coverageGapArtifactIds": [], + "key": { + "confidence": "low", + "endpointHandle": "synthetic:subject:provider-01", + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "operationHandle": "cmtraceopen.operation.sha256.v1:81b06444c4b09f92d8fbcf959a2aa771feb38d74be73dec8a8a333797d515fa1", + "producerHostHandle": "synthetic:host:provider-01", + "requestHandle": "cmtraceopen.request.sha256.v1:0cc4a44c3fed5ad67d93ffcf74331cf00b27aead47742bdcc118c0a268e52388" + }, + "lastSuccessfulPhase": null, + "layer": "provider", + "nextArtifactRequests": [ + { + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "request": { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + }, + "sourceVersion": "5.00.TEST", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "observations": [ + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-timeout-current", + "entryId": "provider-timeout-current:1-1", + "lineEnd": 1, + "lineStart": 1 + } + ], + "observationId": "provider-timeout-current:1-1-01", + "phase": "receive", + "terminal": false + }, + { + "disposition": "succeeded", + "evidence": [ + { + "artifactId": "provider-timeout-current", + "entryId": "provider-timeout-current:2-2", + "lineEnd": 2, + "lineStart": 2 + } + ], + "observationId": "provider-timeout-current:2-2-02", + "phase": "authenticateOrAuthorize", + "terminal": false + }, + { + "disposition": "pending", + "evidence": [ + { + "artifactId": "provider-timeout-current", + "entryId": "provider-timeout-current:3-3", + "lineEnd": 3, + "lineStart": 3 + } + ], + "observationId": "provider-timeout-current:3-3-03", + "phase": "executeProviderOperation", + "terminal": false + } + ], + "producerRole": "provider", + "publicSummary": "Provider evidence is incomplete, contradictory, or not comparably ordered.", + "sourceVersion": "5.00.TEST", + "state": "incomplete", + "terminalEvidence": false, + "timestampOrdering": "unusable", + "topologyCompatibility": "exact", + "transactionId": "provider:cmtraceopen.request.sha256.v1:0cc4a44c3fed5ad67d93ffcf74331cf00b27aead47742bdcc118c0a268e52388:cmtraceopen.operation.sha256.v1:81b06444c4b09f92d8fbcf959a2aa771feb38d74be73dec8a8a333797d515fa1:synthetic:host:provider-01:synthetic:subject:provider-01" + } + ], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/manifest.json new file mode 100644 index 000000000..4558fa087 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/manifest.json @@ -0,0 +1,50 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "provider-timeout-current", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 1234, + "sourceVersion": "5.00.TEST", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log new file mode 100644 index 000000000..259f89693 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/expected.json new file mode 100644 index 000000000..0fdae4b9f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/expected.json @@ -0,0 +1,150 @@ +{ + "artifactRequests": [ + { + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "request": { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + }, + "sourceVersion": null, + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "coverage": [ + { + "artifactId": "rotation-01-current", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": null, + "state": "parseFailed", + "workflowSubjectHandle": "synthetic:subject:provider-01" + }, + { + "artifactId": "rotation-02-lo", + "producerHostHandle": "synthetic:host:provider-01", + "producerRole": "provider", + "sourceId": "server-provider", + "sourceVersion": null, + "state": "parseFailed", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "crossSideCausalClaims": [], + "findings": [ + { + "finding": { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "rotation-01-current", + "coverage": "parseFailed", + "role": "provider" + } + ], + "evidence": [], + "findingId": "provider-admin-coverage:rotation-01-current", + "nextArtifacts": [ + { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + } + ], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Warning", + "summary": "Provider cannot be evaluated because its scoped source is not a complete capture.", + "terminalEvidence": [], + "title": "Provider evidence unavailable" + }, + "lastSuccessfulPhase": null, + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": null, + "subjectId": "rotation-01-current", + "workflowSubjectHandle": "synthetic:subject:provider-01" + }, + { + "finding": { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "rotation-02-lo", + "coverage": "parseFailed", + "role": "provider" + } + ], + "evidence": [], + "findingId": "provider-admin-coverage:rotation-02-lo", + "nextArtifacts": [ + { + "logicalId": "smsprov", + "reason": "Collect the complete Smsprov.log file.", + "role": "provider" + } + ], + "phase": "providerAndAdminService", + "role": "provider", + "severity": "Warning", + "summary": "Provider cannot be evaluated because its scoped source is not a complete capture.", + "terminalEvidence": [], + "title": "Provider evidence unavailable" + }, + "lastSuccessfulPhase": null, + "layer": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "sourceId": "server-provider", + "sourceVersion": null, + "subjectId": "rotation-02-lo", + "workflowSubjectHandle": "synthetic:subject:provider-01" + } + ], + "profiles": [ + { + "extractionProfile": { + "configmgrVersionPrefixes": [ + "5.00.TEST" + ], + "maturity": "experimental", + "profileId": "provider-server-5.00.test-v1", + "selectedConfigmgrVersion": "5.00.TEST", + "validatedArtifactFamilies": [ + "provider" + ] + }, + "layer": "provider", + "limitation": "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + "selectionState": "unknownVersion" + } + ], + "sourceLocalObservations": [ + { + "artifactIds": [ + "rotation-01-current" + ], + "correlationEligible": false, + "kind": "rotationFragment", + "observationId": "rotation-01-current-rotation" + }, + { + "artifactIds": [ + "rotation-02-lo" + ], + "correlationEligible": false, + "kind": "rotationFragment", + "observationId": "rotation-02-lo-rotation" + } + ], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/manifest.json new file mode 100644 index 000000000..2332dbc88 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/manifest.json @@ -0,0 +1,81 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, + "artifacts": [ + { + "artifactId": "rotation-01-current", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "current", + "lineageId": "provider-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 127, + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "fragmentComplete": false, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log" + }, + { + "artifactId": "rotation-02-lo", + "producerRole": "provider", + "producerHostHandle": "synthetic:host:provider-01", + "workflowSubject": { + "role": "provider", + "instanceHandle": "synthetic:subject:provider-01" + }, + "sourceId": "server-provider", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_PROVIDER_ROOT", + "originalBasename": "Smsprov.lo_", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, + "rotation": { + "kind": "lo_", + "lineageId": "provider-primary" + }, + "captureState": "captured", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 295, + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "fragmentComplete": false, + "relativePath": "evidence/sccm/server/provider/server-provider/subject-provider/lo_/Smsprov.lo_" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log new file mode 100644 index 000000000..75613203a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/expected.json new file mode 100644 index 000000000..28545de8a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/expected.json @@ -0,0 +1,186 @@ +{ + "schemaVersion": 1, + "workflow": "siteCore", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], + "results": [ + { + "resultId": "site-core:result:v1:a3220d3e9eebc073daf7575a24654c9c3174140ad298b9b2d72b4ebfa95b957d", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-COMPFAIL-001" + }, + "state": "terminalFailure", + "lastSuccessfulPhase": "componentWork", + "findingClass": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3, + "terminal": true + } + ], + "coverageGapArtifactIds": ["z-site-status"], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [ + { + "observationId": "site-core:observation:v1:3c9d38e20784d06345b49e9978c0ee0a106b87001ac62428d18c41115f72a2f0", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["z-site-status"], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } + ] + } + ], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "sourceId": "server-status", + "state": "absent", + "reasonCode": "required-source-absent", + "diagnosticMeaning": "coverageOnly" + } + ], + "findings": [ + { + "findingId": "site-core:finding:v1:3904d5916025a996b54227f390835cf3a9417264aa9d80d592e59e58793fe0ee", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:observation:v1:3c9d38e20784d06345b49e9978c0ee0a106b87001ac62428d18c41115f72a2f0", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:e8e0a9faadf8fc5d56fc8540208dd8e5dce0cb60ff0a27e4bee6cb36d1f36d3f", + "class": "confirmedFailure", + "phase": "componentWork", + "role": "siteServer", + "severity": "Error", + "confidence": "high", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is componentWork; later phases are bounded to cited evidence.", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + } + ], + "terminalEvidence": [ + { + "reference": { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + "kind": "observedFailure" + } + ], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [], + "subjectId": "site-core:result:v1:a3220d3e9eebc073daf7575a24654c9c3174140ad298b9b2d72b4ebfa95b957d", + "lastSuccessfulPhase": "componentWork" + } + ], + "artifactRequests": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } + ], + "crossSideCorrelationPerformed": false +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/manifest.json new file mode 100644 index 000000000..f8192b1a0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/manifest.json @@ -0,0 +1,55 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": ["siteServer"] + }, + "artifacts": [ + { + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "sitecomp.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:site-default" + }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:11:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", + "bytesCopied": 1000 + }, + { + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "statmgr.log", + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:z-site" + }, + "defaultCandidateState": "absentCandidateOnly", + "rotation": { "kind": "current", "lineageId": "site-status-z" }, + "captureState": "absent", + "collectedUtc": "2026-07-30T14:11:00Z", + "relativePath": null, + "bytesCopied": 0 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log new file mode 100644 index 000000000..cbe24eff5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-status/current/statmgr.log new file mode 100644 index 000000000..5c068f3dd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-status/current/statmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/expected.json new file mode 100644 index 000000000..14488c594 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/expected.json @@ -0,0 +1,159 @@ +{ + "schemaVersion": 1, + "workflow": "siteCore", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], + "results": [ + { + "resultId": "site-core:result:v1:17a3f55ffd5eb73fa686eb49ba0ee620d965de9a33ba240d7e87cba666952884", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-CONTRA-EXEC-001" + }, + "state": "terminalFailure", + "lastSuccessfulPhase": "componentWork", + "findingClass": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:4-4", + "lineStart": 4, + "lineEnd": 4 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:6-6", + "lineStart": 6, + "lineEnd": 6, + "terminal": true + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + }, + { + "resultId": "site-core:result:v1:5d5fc621e38bc0572b168833e7bef6fa352cb2a35414e52a97bfd998a8a1c1b3", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_DISTRIBUTION_MANAGER", + "workItemId": "SC-CONTRA-DIST-001" + }, + "state": "healthy", + "lastSuccessfulPhase": "healthyOrTerminal", + "findingClass": null, + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:5-5", + "lineStart": 5, + "lineEnd": 5 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "findings": [ + { + "findingId": "site-core:finding:v1:0dc933141f9f40354f35c580303bdb6c5b47f287e5829f055620a73e75277650", + "class": "confirmedFailure", + "phase": "componentWork", + "role": "siteServer", + "severity": "Error", + "confidence": "high", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is componentWork; later phases are bounded to cited evidence.", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:4-4", + "lineStart": 4, + "lineEnd": 4 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:6-6", + "lineStart": 6, + "lineEnd": 6 + } + ], + "terminalEvidence": [ + { + "reference": { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:6-6", + "lineStart": 6, + "lineEnd": 6 + }, + "kind": "observedFailure" + } + ], + "coverageGaps": [], + "correlationKeys": [], + "nextArtifacts": [], + "subjectId": "site-core:result:v1:17a3f55ffd5eb73fa686eb49ba0ee620d965de9a33ba240d7e87cba666952884", + "lastSuccessfulPhase": "componentWork" + } + ], + "artifactRequests": [], + "crossSideCorrelationPerformed": false +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/manifest.json new file mode 100644 index 000000000..2ecf8f066 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/manifest.json @@ -0,0 +1,46 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, + "artifacts": [ + { + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:51:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", + "bytesCopied": 2003 + }, + { + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:51:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", + "bytesCopied": 685 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log new file mode 100644 index 000000000..b2b427fd2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-status/current/statmgr.log new file mode 100644 index 000000000..9b081c99c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-status/current/statmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/expected.json new file mode 100644 index 000000000..9e1f6346d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/expected.json @@ -0,0 +1,73 @@ +{ + "schemaVersion": 1, + "workflow": "siteCore", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], + "results": [ + { + "resultId": "site-core:result:v1:4333cffa83f498b6a451e36269b164ae23717b1e790f4e0af31beb4821eece3b", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-HEALTH-001" + }, + "state": "healthy", + "lastSuccessfulPhase": "healthyOrTerminal", + "findingClass": null, + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "findings": [], + "artifactRequests": [], + "crossSideCorrelationPerformed": false +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/manifest.json new file mode 100644 index 000000000..01f2112c9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/manifest.json @@ -0,0 +1,56 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": ["siteServer"] + }, + "artifacts": [ + { + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "sitecomp.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:site-default" + }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:01:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", + "bytesCopied": 983 + }, + { + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "statmgr.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:z-site" + }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:01:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", + "bytesCopied": 653 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log new file mode 100644 index 000000000..5974135df --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/expected.json new file mode 100644 index 000000000..a42e67a60 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/expected.json @@ -0,0 +1,213 @@ +{ + "schemaVersion": 1, + "workflow": "siteCore", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], + "results": [ + { + "resultId": "site-core:result:v1:8f00cd22916faa6850e60171e8392ee5dd7aa55efe883c7d76546a3c3d221f1f", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-BACKLOG-001" + }, + "state": "blockedOrDeferred", + "lastSuccessfulPhase": "componentWork", + "findingClass": "blockedOrDeferred", + "confidence": "low", + "confidenceCeiling": "low", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3, + "terminal": false + } + ], + "coverageGapArtifactIds": ["z-site-status"], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "matching-status-terminal-evidence-missing", + "candidates": [ + { "basename": "statmgr.log", "rotation": "current" }, + { "basename": "statmgr.lo_", "rotation": "loUnderscore" } + ], + "maxArtifacts": 2, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-BACKLOG-001" + } + } + ] + } + ], + "unlinkedObservations": [ + { + "observationId": "site-core:observation:v1:3c9d38e20784d06345b49e9978c0ee0a106b87001ac62428d18c41115f72a2f0", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["z-site-status"], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } + ] + } + ], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "sourceId": "server-status", + "state": "absent", + "reasonCode": "required-source-absent", + "diagnosticMeaning": "coverageOnly" + } + ], + "findings": [ + { + "findingId": "site-core:finding:v1:3904d5916025a996b54227f390835cf3a9417264aa9d80d592e59e58793fe0ee", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:observation:v1:3c9d38e20784d06345b49e9978c0ee0a106b87001ac62428d18c41115f72a2f0", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:7f88716d8276aa14790bb220f91a66086fdc10d86f55a5aeb831f2aacf2944ef", + "class": "blockedOrDeferred", + "phase": "componentWork", + "role": "siteServer", + "severity": "Warning", + "confidence": "low", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is componentWork; later phases are bounded to cited evidence.", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + } + ], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:result:v1:8f00cd22916faa6850e60171e8392ee5dd7aa55efe883c7d76546a3c3d221f1f", + "lastSuccessfulPhase": "componentWork" + } + ], + "artifactRequests": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "matching-status-terminal-evidence-missing", + "candidates": [ + { "basename": "statmgr.log", "rotation": "current" }, + { "basename": "statmgr.lo_", "rotation": "loUnderscore" } + ], + "maxArtifacts": 2, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-BACKLOG-001" + } + }, + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } + ], + "crossSideCorrelationPerformed": false +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/manifest.json new file mode 100644 index 000000000..a3096d550 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/manifest.json @@ -0,0 +1,55 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": ["siteServer"] + }, + "artifacts": [ + { + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "sitecomp.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:site-default" + }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:21:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", + "bytesCopied": 1000 + }, + { + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "statmgr.log", + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:z-site" + }, + "defaultCandidateState": "absentCandidateOnly", + "rotation": { "kind": "current", "lineageId": "site-status-z" }, + "captureState": "absent", + "collectedUtc": "2026-07-30T14:21:00Z", + "relativePath": null, + "bytesCopied": 0 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log new file mode 100644 index 000000000..a35c41dfd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log @@ -0,0 +1,3 @@ + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-status/current/statmgr.log new file mode 100644 index 000000000..5f90c258c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-status/current/statmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/expected.json new file mode 100644 index 000000000..ad304a594 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/expected.json @@ -0,0 +1,125 @@ +{ + "schemaVersion": 1, + "workflow": "siteCore", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], + "results": [ + { + "resultId": "site-core:result:v1:6481f8fcb2dc2b2d95e759d00b6c5332fc83f74a8ce808c4079681340c0547a9", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-RECOVER-001" + }, + "state": "recovered", + "lastSuccessfulPhase": "healthyOrTerminal", + "findingClass": "symptom", + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3, + "terminal": true + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2, + "terminal": true, + "recovery": true + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "findings": [ + { + "findingId": "site-core:finding:v1:0dc72fd549b222924cd7a0207cae9a9e127f0ce27126a7091697d14cd8dfc0fe", + "class": "symptom", + "phase": "healthyOrTerminal", + "role": "siteServer", + "severity": "Warning", + "confidence": "high", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is healthyOrTerminal; later phases are bounded to cited evidence.", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2 + } + ], + "terminalEvidence": [], + "coverageGaps": [], + "correlationKeys": [], + "nextArtifacts": [], + "subjectId": "site-core:result:v1:6481f8fcb2dc2b2d95e759d00b6c5332fc83f74a8ce808c4079681340c0547a9", + "lastSuccessfulPhase": "healthyOrTerminal" + } + ], + "artifactRequests": [], + "crossSideCorrelationPerformed": false +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/manifest.json new file mode 100644 index 000000000..46aa8fd77 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/manifest.json @@ -0,0 +1,46 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, + "artifacts": [ + { + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:43:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", + "bytesCopied": 997 + }, + { + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:43:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", + "bytesCopied": 657 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log new file mode 100644 index 000000000..9fdaea805 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log @@ -0,0 +1 @@ + statusId=SC_COMPONENT_TERMINAL_FAILURE outcome=failure terminal=true]LOG]!> diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_ new file mode 100644 index 000000000..70d0ed057 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_ @@ -0,0 +1 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-status/current/statmgr.log new file mode 100644 index 000000000..4ab51ce92 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-status/current/statmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/expected.json new file mode 100644 index 000000000..7421e5277 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/expected.json @@ -0,0 +1,133 @@ +{ + "schemaVersion": 1, + "workflow": "siteCore", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], + "results": [ + { + "resultId": "site-core:result:v1:234575fad725c1b8673aaa8d9c02e5465aad0b919b0de5c4b52b4221098d0eb1", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-STATUSFAIL-001" + }, + "state": "terminalFailure", + "lastSuccessfulPhase": "statusOrStateProcessing", + "findingClass": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2, + "terminal": true + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "findings": [ + { + "findingId": "site-core:finding:v1:853fbf3e3970fd2b5acba277229471609617eb1f25b7932e01e7c67e5308f93d", + "class": "confirmedFailure", + "phase": "statusOrStateProcessing", + "role": "siteServer", + "severity": "Error", + "confidence": "high", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is statusOrStateProcessing; later phases are bounded to cited evidence.", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2 + } + ], + "terminalEvidence": [ + { + "reference": { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + "kind": "observedFailure" + } + ], + "coverageGaps": [], + "correlationKeys": [], + "nextArtifacts": [], + "subjectId": "site-core:result:v1:234575fad725c1b8673aaa8d9c02e5465aad0b919b0de5c4b52b4221098d0eb1", + "lastSuccessfulPhase": "statusOrStateProcessing" + } + ], + "artifactRequests": [], + "crossSideCorrelationPerformed": false +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/manifest.json new file mode 100644 index 000000000..bd4375e95 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/manifest.json @@ -0,0 +1,46 @@ +{ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, + "artifacts": [ + { + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:31:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", + "bytesCopied": 995 + }, + { + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:31:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", + "bytesCopied": 667 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/README.md new file mode 100644 index 000000000..ec4d27b55 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/README.md @@ -0,0 +1,21 @@ +# Synthetic Software Update Point corpus + +This directory is the preparation-only fixture corpus for issue #330. Every +record is synthetic and uses opaque `safe:` handles plus `SYNTHETIC://` path +provenance. The raw `.log` files remain CCM transport and are consumed through +the shared SCCM logical-record normalizer. + +The matrix covers successful synchronization, WCM configuration failure, WSUS +health failure, retry/deferred synchronization, metadata failure, SUP setup +failure, optional WSUS coverage skipped, an unrelated client update key, +rotation-split fragments, and incomplete access/absence coverage. + +Every file beneath a scenario's `evidence/` tree is closed by that scenario's +manifest and expected coverage. Bytes used only by adversarial mutation tests +live in the sibling `software_update_point_mutation_assets` contract so they +cannot masquerade as collected scenario evidence. + +These fixtures do not assert a production reducer, live Windows collection, +role absence, client impact, or cross-side causality. Production work remains +dependent on the reviewed #318 and #335 contracts; #333 owns any later +cross-side correlation. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/evidence/server-sup-sync/site/current/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/evidence/server-sup-sync/site/current/WCM.log new file mode 100644 index 000000000..b4e37ba6e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/evidence/server-sup-sync/site/current/WCM.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/expected.json new file mode 100644 index 000000000..b9cb70295 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "incomplete", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [ + {"artifactId":"incomplete-01-wcm","state":"captured"}, + {"artifactId":"incomplete-02-wsync-denied","state":"accessDenied"}, + {"artifactId":"incomplete-03-wsus-absent","state":"absent"} + ], + "transactions": [{ + "transactionId": "sup:sync-10:LAB:safe:sup:lab-sup-01", + "key": {"syncRunId":"sync-10","siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","updateId":null,"kbId":null,"confidence":"exact","extractionProfileId":"sup-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "incomplete", + "classification": "insufficientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "lastSuccessfulPhase": "configure", + "nextSourceId": "server-sup-sync", + "coverageGapArtifactIds": ["incomplete-02-wsync-denied","incomplete-03-wsus-absent"], + "observations": [ + {"observationId":"sync-10-01-configure","phase":"configure","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"incomplete-01-wcm","startLine":1,"endLine":1}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [ + {"sourceId":"server-sup-sync","reasonCode":"coverageAbsent"}, + {"sourceId":"server-sup-sync","reasonCode":"coverageAccessDenied"} + ], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/manifest.json new file mode 100644 index 000000000..167f80893 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/manifest.json @@ -0,0 +1,19 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "incomplete", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [ + { + "artifactId":"incomplete-01-wcm","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WCM.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/WCM.log","pathFingerprint":"synthetic:incomplete-wcm","rotation":{"kind":"current","lineageId":"incomplete-wcm","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":298,"relativePath":"evidence/server-sup-sync/site/current/WCM.log" + }, + { + "artifactId":"incomplete-02-wsync-denied","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"wsyncmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/wsyncmgr.log","pathFingerprint":"synthetic:incomplete-wsync-denied","rotation":{"kind":"current","lineageId":"incomplete-wsync"},"captureState":"accessDenied","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z" + }, + { + "artifactId":"incomplete-03-wsus-absent","sourceId":"server-sup-sync","producerRole":"softwareUpdatePoint","producerHostHandle":"safe:sup:lab-sup-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WSUSCtrl.log","sanitizedSourcePath":"SYNTHETIC://configured-root/SUP/Logs/WSUSCtrl.log","pathFingerprint":"synthetic:incomplete-wsus-absent","rotation":{"kind":"current","lineageId":"incomplete-wsus"},"captureState":"absent","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/evidence/server-sup-sync/site/current/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/evidence/server-sup-sync/site/current/WCM.log new file mode 100644 index 000000000..54f0b11ec --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/evidence/server-sup-sync/site/current/WCM.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/evidence/server-sup-sync/site/current/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/evidence/server-sup-sync/site/current/wsyncmgr.log new file mode 100644 index 000000000..7d4ee74db --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/evidence/server-sup-sync/site/current/wsyncmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/expected.json new file mode 100644 index 000000000..4a5f669b9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/expected.json @@ -0,0 +1,35 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "metadata-failure", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [ + {"artifactId":"metadata-failure-01-wcm","state":"captured"}, + {"artifactId":"metadata-failure-02-wsync","state":"captured"} + ], + "transactions": [{ + "transactionId": "sup:sync-05:LAB:safe:sup:lab-sup-01", + "key": {"syncRunId":"sync-05","siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","updateId":null,"kbId":null,"confidence":"exact","extractionProfileId":"sup-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "synchronize", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId":"sync-05-01-configure","phase":"configure","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"metadata-failure-01-wcm","startLine":1,"endLine":1}]}, + {"observationId":"sync-05-02-synchronize","phase":"synchronize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"metadata-failure-02-wsync","startLine":1,"endLine":1}]}, + {"observationId":"sync-05-03-import","phase":"importOrProcessMetadata","disposition":"failed","terminal":true,"evidence":[{"artifactId":"metadata-failure-02-wsync","startLine":2,"endLine":2}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/manifest.json new file mode 100644 index 000000000..86626d62b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/manifest.json @@ -0,0 +1,16 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "metadata-failure", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [ + { + "artifactId":"metadata-failure-01-wcm","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WCM.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/WCM.log","pathFingerprint":"synthetic:metadata-failure-wcm","rotation":{"kind":"current","lineageId":"metadata-failure-wcm","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":297,"relativePath":"evidence/server-sup-sync/site/current/WCM.log" + }, + { + "artifactId":"metadata-failure-02-wsync","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"wsyncmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/wsyncmgr.log","pathFingerprint":"synthetic:metadata-failure-wsync","rotation":{"kind":"current","lineageId":"metadata-failure-wsync","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":652,"relativePath":"evidence/server-sup-sync/site/current/wsyncmgr.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/current/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/current/wsyncmgr.log new file mode 100644 index 000000000..ffbedd7e0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/current/wsyncmgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/sup/current/WSUSCtrl.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/sup/current/WSUSCtrl.log new file mode 100644 index 000000000..3a1e9c02a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/sup/current/WSUSCtrl.log @@ -0,0 +1 @@ +SYNTHETIC FIXTURE malformed WSUS control bytes without a CCM record diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/expected.json new file mode 100644 index 000000000..4b02e2f10 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/expected.json @@ -0,0 +1,41 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "rotation-boundary", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [ + {"artifactId":"rotation-01-current","state":"captured"}, + {"artifactId":"rotation-02-lo","state":"captured"}, + {"artifactId":"rotation-03-malformed","state":"parseFailed"} + ], + "transactions": [], + "sourceLocalObservations": [ + { + "observationId":"rotation-01-split", + "classification":"rotationSplit", + "confidence":"low", + "confidenceCeiling":"low", + "correlationEligible":false, + "artifactIds":["rotation-01-current","rotation-02-lo"], + "evidence":[] + }, + { + "observationId":"rotation-02-malformed", + "classification":"malformedEvidence", + "confidence":"low", + "confidenceCeiling":"low", + "correlationEligible":false, + "artifactIds":["rotation-03-malformed"], + "evidence":[] + } + ], + "artifactRequests": [ + {"sourceId":"server-sup-sync","reasonCode":"coverageMalformed"}, + {"sourceId":"server-sup-sync","reasonCode":"coverageRotationSplit"} + ], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/manifest.json new file mode 100644 index 000000000..3d2cd2a3f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/manifest.json @@ -0,0 +1,19 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "rotation-boundary", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [ + { + "artifactId":"rotation-01-current","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"wsyncmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/wsyncmgr.log","pathFingerprint":"synthetic:rotation-wsync-current","rotation":{"kind":"current","lineageId":"rotation-sync-09","fragmentComplete":false},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":182,"relativePath":"evidence/server-sup-sync/site/current/wsyncmgr.log" + }, + { + "artifactId":"rotation-02-lo","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"wsyncmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/wsyncmgr.lo_","pathFingerprint":"synthetic:rotation-wsync-lo","rotation":{"kind":"lo_","lineageId":"rotation-sync-09","fragmentComplete":false},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":192,"relativePath":"evidence/server-sup-sync/site/lo_/wsyncmgr.log" + }, + { + "artifactId":"rotation-03-malformed","sourceId":"server-sup-sync","producerRole":"softwareUpdatePoint","producerHostHandle":"safe:sup:lab-sup-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WSUSCtrl.log","sanitizedSourcePath":"SYNTHETIC://configured-root/SUP/Logs/WSUSCtrl.log","pathFingerprint":"synthetic:rotation-wsus-malformed","rotation":{"kind":"current","lineageId":"rotation-wsus-malformed","fragmentComplete":true},"captureState":"parseFailed","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":68,"relativePath":"evidence/server-sup-sync/sup/current/WSUSCtrl.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/evidence/server-sup-sync/sup/current/SUPSetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/evidence/server-sup-sync/sup/current/SUPSetup.log new file mode 100644 index 000000000..3eec19b63 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/evidence/server-sup-sync/sup/current/SUPSetup.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/expected.json new file mode 100644 index 000000000..2739af0b8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/expected.json @@ -0,0 +1,30 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "sup-setup-failure", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [{"artifactId":"sup-setup-failure-01-setup","state":"captured"}], + "transactions": [{ + "transactionId": "sup:sync-06:LAB:safe:sup:lab-sup-01", + "key": {"syncRunId":"sync-06","siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","updateId":null,"kbId":null,"confidence":"exact","extractionProfileId":"sup-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": null, + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId":"sync-06-01-configure","phase":"configure","disposition":"failed","terminal":true,"evidence":[{"artifactId":"sup-setup-failure-01-setup","startLine":1,"endLine":1}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/manifest.json new file mode 100644 index 000000000..916b2a4b0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "sup-setup-failure", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [{ + "artifactId":"sup-setup-failure-01-setup","sourceId":"server-sup-sync","producerRole":"softwareUpdatePoint","producerHostHandle":"safe:sup:lab-sup-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"SUPSetup.log","sanitizedSourcePath":"SYNTHETIC://configured-root/SUP/Logs/SUPSetup.log","pathFingerprint":"synthetic:sup-setup-failure","rotation":{"kind":"current","lineageId":"sup-setup-failure","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":325,"relativePath":"evidence/server-sup-sync/sup/current/SUPSetup.log" + }] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/site/current/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/site/current/WCM.log new file mode 100644 index 000000000..b65758ad8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/site/current/WCM.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/site/current/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/site/current/wsyncmgr.log new file mode 100644 index 000000000..b2d8a6142 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/site/current/wsyncmgr.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/sup/current/WSUSCtrl.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/sup/current/WSUSCtrl.log new file mode 100644 index 000000000..3e193ca73 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/sup/current/WSUSCtrl.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/expected.json new file mode 100644 index 000000000..2c41316e7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/expected.json @@ -0,0 +1,40 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "supplemental-wsus-skipped", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [ + {"artifactId":"supplemental-01-wcm","state":"captured"}, + {"artifactId":"supplemental-02-wsync","state":"captured"}, + {"artifactId":"supplemental-03-wsus","state":"captured"}, + {"artifactId":"supplemental-04-wsus-health","state":"skipped"} + ], + "transactions": [{ + "transactionId": "sup:sync-07:LAB:safe:sup:lab-sup-01", + "key": {"syncRunId":"sync-07","siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","updateId":null,"kbId":null,"confidence":"exact","extractionProfileId":"sup-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "succeeded", + "classification": "success", + "confidence": "medium", + "confidenceCeiling": "medium", + "lastSuccessfulPhase": "healthyOrTerminal", + "nextSourceId": null, + "coverageGapArtifactIds": ["supplemental-04-wsus-health"], + "observations": [ + {"observationId":"sync-07-01-configure","phase":"configure","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"supplemental-01-wcm","startLine":1,"endLine":1}]}, + {"observationId":"sync-07-02-synchronize","phase":"synchronize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"supplemental-02-wsync","startLine":1,"endLine":1}]}, + {"observationId":"sync-07-03-import","phase":"importOrProcessMetadata","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"supplemental-02-wsync","startLine":2,"endLine":2}]}, + {"observationId":"sync-07-04-validate","phase":"validateWsus","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"supplemental-03-wsus","startLine":1,"endLine":1}]}, + {"observationId":"sync-07-05-publish","phase":"publishAvailability","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"supplemental-02-wsync","startLine":3,"endLine":3}]}, + {"observationId":"sync-07-06-terminal","phase":"healthyOrTerminal","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"supplemental-03-wsus","startLine":2,"endLine":2}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/manifest.json new file mode 100644 index 000000000..1f1cef875 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/manifest.json @@ -0,0 +1,22 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "supplemental-wsus-skipped", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [ + { + "artifactId":"supplemental-01-wcm","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WCM.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/WCM.log","pathFingerprint":"synthetic:supplemental-wcm","rotation":{"kind":"current","lineageId":"supplemental-wcm","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":297,"relativePath":"evidence/server-sup-sync/site/current/WCM.log" + }, + { + "artifactId":"supplemental-02-wsync","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"wsyncmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/wsyncmgr.log","pathFingerprint":"synthetic:supplemental-wsync","rotation":{"kind":"current","lineageId":"supplemental-wsync","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":986,"relativePath":"evidence/server-sup-sync/site/current/wsyncmgr.log" + }, + { + "artifactId":"supplemental-03-wsus","sourceId":"server-sup-sync","producerRole":"softwareUpdatePoint","producerHostHandle":"safe:sup:lab-sup-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WSUSCtrl.log","sanitizedSourcePath":"SYNTHETIC://configured-root/SUP/Logs/WSUSCtrl.log","pathFingerprint":"synthetic:supplemental-wsus","rotation":{"kind":"current","lineageId":"supplemental-wsus","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":656,"relativePath":"evidence/server-sup-sync/sup/current/WSUSCtrl.log" + }, + { + "artifactId":"supplemental-04-wsus-health","sourceId":"server-sup-wsus","producerRole":"wsUs","producerHostHandle":"safe:wsus:lab-wsus-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"profileDefined","originalBasename":"WsusHealth.json","sanitizedSourcePath":"SYNTHETIC://configured-root/WSUS/WsusHealth.json","pathFingerprint":"synthetic:supplemental-wsus-health","rotation":{"kind":"current","lineageId":"supplemental-wsus-health"},"captureState":"skipped","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/evidence/server-sup-sync/site/current/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/evidence/server-sup-sync/site/current/WCM.log new file mode 100644 index 000000000..d7d221555 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/evidence/server-sup-sync/site/current/WCM.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/evidence/server-sup-sync/site/current/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/evidence/server-sup-sync/site/current/wsyncmgr.log new file mode 100644 index 000000000..ac2a29502 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/evidence/server-sup-sync/site/current/wsyncmgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/expected.json new file mode 100644 index 000000000..4b04ac3b9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/expected.json @@ -0,0 +1,34 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "sync-retry", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [ + {"artifactId":"sync-retry-01-wcm","state":"captured"}, + {"artifactId":"sync-retry-02-wsync","state":"captured"} + ], + "transactions": [{ + "transactionId": "sup:sync-04:LAB:safe:sup:lab-sup-01", + "key": {"syncRunId":"sync-04","siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","updateId":null,"kbId":null,"confidence":"exact","extractionProfileId":"sup-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "deferred", + "classification": "blockedOrDeferred", + "confidence": "medium", + "confidenceCeiling": "medium", + "lastSuccessfulPhase": "configure", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId":"sync-04-01-configure","phase":"configure","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"sync-retry-01-wcm","startLine":1,"endLine":1}]}, + {"observationId":"sync-04-02-retry","phase":"synchronize","disposition":"retrying","terminal":false,"evidence":[{"artifactId":"sync-retry-02-wsync","startLine":1,"endLine":1}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/manifest.json new file mode 100644 index 000000000..1dd35e698 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/manifest.json @@ -0,0 +1,16 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "sync-retry", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [ + { + "artifactId":"sync-retry-01-wcm","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WCM.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/WCM.log","pathFingerprint":"synthetic:sync-retry-wcm","rotation":{"kind":"current","lineageId":"sync-retry-wcm","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":297,"relativePath":"evidence/server-sup-sync/site/current/WCM.log" + }, + { + "artifactId":"sync-retry-02-wsync","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"wsyncmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/wsyncmgr.log","pathFingerprint":"synthetic:sync-retry-wsync","rotation":{"kind":"current","lineageId":"sync-retry-wsync","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":321,"relativePath":"evidence/server-sup-sync/site/current/wsyncmgr.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/current/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/current/WCM.log new file mode 100644 index 000000000..745ff5df2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/current/WCM.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/current/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/current/wsyncmgr.log new file mode 100644 index 000000000..57850512e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/current/wsyncmgr.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/sup/current/WSUSCtrl.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/sup/current/WSUSCtrl.log new file mode 100644 index 000000000..7e7984908 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/sup/current/WSUSCtrl.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/expected.json new file mode 100644 index 000000000..738681de4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/expected.json @@ -0,0 +1,39 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "sync-success", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [ + {"artifactId":"sync-success-01-wcm","state":"captured"}, + {"artifactId":"sync-success-02-wsync","state":"captured"}, + {"artifactId":"sync-success-03-wsus","state":"captured"} + ], + "transactions": [{ + "transactionId": "sup:sync-01:LAB:safe:sup:lab-sup-01", + "key": {"syncRunId":"sync-01","siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","updateId":null,"kbId":null,"confidence":"exact","extractionProfileId":"sup-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "succeeded", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "healthyOrTerminal", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId":"sync-01-01-configure","phase":"configure","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"sync-success-01-wcm","startLine":1,"endLine":1}]}, + {"observationId":"sync-01-02-synchronize","phase":"synchronize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"sync-success-02-wsync","startLine":1,"endLine":1}]}, + {"observationId":"sync-01-03-import","phase":"importOrProcessMetadata","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"sync-success-02-wsync","startLine":2,"endLine":2}]}, + {"observationId":"sync-01-04-validate","phase":"validateWsus","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"sync-success-03-wsus","startLine":1,"endLine":1}]}, + {"observationId":"sync-01-05-publish","phase":"publishAvailability","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"sync-success-02-wsync","startLine":3,"endLine":3}]}, + {"observationId":"sync-01-06-terminal","phase":"healthyOrTerminal","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"sync-success-03-wsus","startLine":2,"endLine":2}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/manifest.json new file mode 100644 index 000000000..9ca593066 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/manifest.json @@ -0,0 +1,70 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "sync-success", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [ + { + "artifactId": "sync-success-01-wcm", + "sourceId": "server-sup-sync", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "softwareUpdatePoint", + "workflowSubjectHandle": "safe:sup:lab-sup-01", + "sourceKind": "ccmLog", + "originalBasename": "WCM.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/Site/Logs/WCM.log", + "pathFingerprint": "synthetic:sync-success-wcm", + "rotation": {"kind":"current","lineageId":"sync-success-wcm","fragmentComplete":true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T18:00:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 297, + "relativePath": "evidence/server-sup-sync/site/current/WCM.log" + }, + { + "artifactId": "sync-success-02-wsync", + "sourceId": "server-sup-sync", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "softwareUpdatePoint", + "workflowSubjectHandle": "safe:sup:lab-sup-01", + "sourceKind": "ccmLog", + "originalBasename": "wsyncmgr.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/Site/Logs/wsyncmgr.log", + "pathFingerprint": "synthetic:sync-success-wsync", + "rotation": {"kind":"current","lineageId":"sync-success-wsync","fragmentComplete":true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T18:00:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 986, + "relativePath": "evidence/server-sup-sync/site/current/wsyncmgr.log" + }, + { + "artifactId": "sync-success-03-wsus", + "sourceId": "server-sup-sync", + "producerRole": "softwareUpdatePoint", + "producerHostHandle": "safe:sup:lab-sup-01", + "workflowSubjectRole": "softwareUpdatePoint", + "workflowSubjectHandle": "safe:sup:lab-sup-01", + "sourceKind": "ccmLog", + "originalBasename": "WSUSCtrl.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/SUP/Logs/WSUSCtrl.log", + "pathFingerprint": "synthetic:sync-success-wsus", + "rotation": {"kind":"current","lineageId":"sync-success-wsus","fragmentComplete":true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T18:00:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 656, + "relativePath": "evidence/server-sup-sync/sup/current/WSUSCtrl.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/client-updates-control/current/WUAHandler.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/client-updates-control/current/WUAHandler.log new file mode 100644 index 000000000..2e4e8ed29 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/client-updates-control/current/WUAHandler.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/site/current/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/site/current/WCM.log new file mode 100644 index 000000000..ef819d10f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/site/current/WCM.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/site/current/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/site/current/wsyncmgr.log new file mode 100644 index 000000000..77967b6a7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/site/current/wsyncmgr.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/sup/current/WSUSCtrl.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/sup/current/WSUSCtrl.log new file mode 100644 index 000000000..8c57e8ffc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/sup/current/WSUSCtrl.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/expected.json new file mode 100644 index 000000000..abb377df5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/expected.json @@ -0,0 +1,48 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "unrelated-update-key", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [ + {"artifactId":"unrelated-01-client","state":"captured"}, + {"artifactId":"unrelated-02-wcm","state":"captured"}, + {"artifactId":"unrelated-03-wsync","state":"captured"}, + {"artifactId":"unrelated-04-wsus","state":"captured"} + ], + "transactions": [{ + "transactionId": "sup:sync-08:LAB:safe:sup:lab-sup-01:update-server-a", + "key": {"syncRunId":"sync-08","siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","updateId":"update-server-a","kbId":"KB5000001","confidence":"exact","extractionProfileId":"sup-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "succeeded", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "healthyOrTerminal", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId":"sync-08-01-configure","phase":"configure","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"unrelated-02-wcm","startLine":1,"endLine":1}]}, + {"observationId":"sync-08-02-synchronize","phase":"synchronize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"unrelated-03-wsync","startLine":1,"endLine":1}]}, + {"observationId":"sync-08-03-import","phase":"importOrProcessMetadata","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"unrelated-03-wsync","startLine":2,"endLine":2}]}, + {"observationId":"sync-08-04-validate","phase":"validateWsus","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"unrelated-04-wsus","startLine":1,"endLine":1}]}, + {"observationId":"sync-08-05-publish","phase":"publishAvailability","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"unrelated-03-wsync","startLine":3,"endLine":3}]}, + {"observationId":"sync-08-06-terminal","phase":"healthyOrTerminal","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"unrelated-04-wsus","startLine":2,"endLine":2}]} + ] + }], + "sourceLocalObservations": [{ + "observationId": "unrelated-client-01", + "classification": "ignoredClientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": ["unrelated-01-client"], + "evidence": [{"artifactId":"unrelated-01-client","startLine":1,"endLine":1}] + }], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/manifest.json new file mode 100644 index 000000000..81b10c8e2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/manifest.json @@ -0,0 +1,22 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "unrelated-update-key", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [ + { + "artifactId":"unrelated-01-client","sourceId":"client-updates-control","producerRole":"client","producerHostHandle":"safe:client:lab-client-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WUAHandler.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Client/Logs/WUAHandler.log","pathFingerprint":"synthetic:unrelated-client","rotation":{"kind":"current","lineageId":"unrelated-client","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":399,"relativePath":"evidence/client-updates-control/current/WUAHandler.log" + }, + { + "artifactId":"unrelated-02-wcm","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WCM.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/WCM.log","pathFingerprint":"synthetic:unrelated-wcm","rotation":{"kind":"current","lineageId":"unrelated-wcm","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":340,"relativePath":"evidence/server-sup-sync/site/current/WCM.log" + }, + { + "artifactId":"unrelated-03-wsync","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"wsyncmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/wsyncmgr.log","pathFingerprint":"synthetic:unrelated-wsync","rotation":{"kind":"current","lineageId":"unrelated-wsync","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1115,"relativePath":"evidence/server-sup-sync/site/current/wsyncmgr.log" + }, + { + "artifactId":"unrelated-04-wsus","sourceId":"server-sup-sync","producerRole":"softwareUpdatePoint","producerHostHandle":"safe:sup:lab-sup-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WSUSCtrl.log","sanitizedSourcePath":"SYNTHETIC://configured-root/SUP/Logs/WSUSCtrl.log","pathFingerprint":"synthetic:unrelated-wsus","rotation":{"kind":"current","lineageId":"unrelated-wsus","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":742,"relativePath":"evidence/server-sup-sync/sup/current/WSUSCtrl.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/evidence/server-sup-sync/site/current/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/evidence/server-sup-sync/site/current/WCM.log new file mode 100644 index 000000000..12f67874a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/evidence/server-sup-sync/site/current/WCM.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/expected.json new file mode 100644 index 000000000..8a0fc16df --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/expected.json @@ -0,0 +1,30 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "wcm-configuration-failure", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [{"artifactId":"wcm-failure-01-wcm","state":"captured"}], + "transactions": [{ + "transactionId": "sup:sync-02:LAB:safe:sup:lab-sup-01", + "key": {"syncRunId":"sync-02","siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","updateId":null,"kbId":null,"confidence":"exact","extractionProfileId":"sup-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": null, + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId":"sync-02-01-configure","phase":"configure","disposition":"failed","terminal":true,"evidence":[{"artifactId":"wcm-failure-01-wcm","startLine":1,"endLine":1}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/manifest.json new file mode 100644 index 000000000..8a54ee44a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/manifest.json @@ -0,0 +1,28 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "wcm-configuration-failure", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [{ + "artifactId": "wcm-failure-01-wcm", + "sourceId": "server-sup-sync", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "softwareUpdatePoint", + "workflowSubjectHandle": "safe:sup:lab-sup-01", + "sourceKind": "ccmLog", + "originalBasename": "WCM.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/Site/Logs/WCM.log", + "pathFingerprint": "synthetic:wcm-configuration-failure", + "rotation": {"kind":"current","lineageId":"wcm-configuration-failure","fragmentComplete":true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T18:00:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit":4096,"limitApplied":false}, + "bytesCopied": 293, + "relativePath": "evidence/server-sup-sync/site/current/WCM.log" + }] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/site/current/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/site/current/WCM.log new file mode 100644 index 000000000..4ab7696c5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/site/current/WCM.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/site/current/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/site/current/wsyncmgr.log new file mode 100644 index 000000000..5c1a220bc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/site/current/wsyncmgr.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/sup/current/WSUSCtrl.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/sup/current/WSUSCtrl.log new file mode 100644 index 000000000..b44705f94 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/sup/current/WSUSCtrl.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/expected.json new file mode 100644 index 000000000..7491b5e8f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "softwareUpdatePoint", + "scenario": "wsus-health-failure", + "stateChain": ["configure","synchronize","importOrProcessMetadata","validateWsus","publishAvailability","healthyOrTerminal"], + "analysisContract": {"independentReducer":true,"consumesClientOutput":false,"crossSideCorrelationPerformed":false}, + "extractionProfile": {"selectionState":"selectedSynthetic","profileId":"sup-server-5.00.test-v1","validatedRole":"softwareUpdatePoint"}, + "roleAssessment": {"softwareUpdatePointObserved":true,"roleAbsentInferred":false,"missingDefaultPathInterpretation":"sourceCoverageOnly"}, + "coverage": [ + {"artifactId":"wsus-failure-01-wcm","state":"captured"}, + {"artifactId":"wsus-failure-02-wsync","state":"captured"}, + {"artifactId":"wsus-failure-03-wsus","state":"captured"} + ], + "transactions": [{ + "transactionId": "sup:sync-03:LAB:safe:sup:lab-sup-01", + "key": {"syncRunId":"sync-03","siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","updateId":null,"kbId":null,"confidence":"exact","extractionProfileId":"sup-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "importOrProcessMetadata", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId":"sync-03-01-configure","phase":"configure","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"wsus-failure-01-wcm","startLine":1,"endLine":1}]}, + {"observationId":"sync-03-02-synchronize","phase":"synchronize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"wsus-failure-02-wsync","startLine":1,"endLine":1}]}, + {"observationId":"sync-03-03-import","phase":"importOrProcessMetadata","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"wsus-failure-02-wsync","startLine":2,"endLine":2}]}, + {"observationId":"sync-03-04-validate","phase":"validateWsus","disposition":"failed","terminal":true,"evidence":[{"artifactId":"wsus-failure-03-wsus","startLine":1,"endLine":1}]} + ] + }], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/manifest.json new file mode 100644 index 000000000..71ef92942 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/manifest.json @@ -0,0 +1,19 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "wsus-health-failure", + "bundle": {"bundleRole":"server","workflow":"softwareUpdatePoint","capturedUtc":"2026-07-30T19:00:00Z"}, + "topology": {"siteCode":"LAB","supHandle":"safe:sup:lab-sup-01","wsusHandle":"safe:wsus:lab-wsus-01","rolesObserved":["siteServer","softwareUpdatePoint","wsUs"]}, + "artifacts": [ + { + "artifactId":"wsus-failure-01-wcm","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WCM.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/WCM.log","pathFingerprint":"synthetic:wsus-failure-wcm","rotation":{"kind":"current","lineageId":"wsus-failure-wcm","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":297,"relativePath":"evidence/server-sup-sync/site/current/WCM.log" + }, + { + "artifactId":"wsus-failure-02-wsync","sourceId":"server-sup-sync","producerRole":"siteServer","producerHostHandle":"safe:server:lab-pri-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"wsyncmgr.log","sanitizedSourcePath":"SYNTHETIC://configured-root/Site/Logs/wsyncmgr.log","pathFingerprint":"synthetic:wsus-failure-wsync","rotation":{"kind":"current","lineageId":"wsus-failure-wsync","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":656,"relativePath":"evidence/server-sup-sync/site/current/wsyncmgr.log" + }, + { + "artifactId":"wsus-failure-03-wsus","sourceId":"server-sup-sync","producerRole":"softwareUpdatePoint","producerHostHandle":"safe:sup:lab-sup-01","workflowSubjectRole":"softwareUpdatePoint","workflowSubjectHandle":"safe:sup:lab-sup-01","sourceKind":"ccmLog","originalBasename":"WSUSCtrl.log","sanitizedSourcePath":"SYNTHETIC://configured-root/SUP/Logs/WSUSCtrl.log","pathFingerprint":"synthetic:wsus-failure-wsus","rotation":{"kind":"current","lineageId":"wsus-failure-wsus","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T18:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":322,"relativePath":"evidence/server-sup-sync/sup/current/WSUSCtrl.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/cross-family-lo-wcm.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/cross-family-lo-wcm.log new file mode 100644 index 000000000..0b356e668 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/cross-family-lo-wcm.log @@ -0,0 +1 @@ +SYNTHETIC FIXTURE rotation tail; SyncRunId=sync-09]LOG]!> diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/incomplete-required-numbered-wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/incomplete-required-numbered-wsyncmgr.log new file mode 100644 index 000000000..ffbedd7e0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/incomplete-required-numbered-wsyncmgr.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/spine/artifact-manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/spine/artifact-manifest.json new file mode 100644 index 000000000..489b6e5cf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/spine/artifact-manifest.json @@ -0,0 +1,59 @@ +[ + { + "artifactId": "client-policy-agent-current", + "displayName": "PolicyAgent.log", + "originalPath": "C:\\Windows\\CCM\\Logs\\PolicyAgent.log", + "host": "LAB-CLIENT-01", + "role": "client", + "configmgrVersion": "5.00.9128.1007", + "collectedAtUtc": "2026-07-30T15:00:00Z", + "rotation": { + "kind": "current" + }, + "coverage": "captured", + "encoding": "utf-8" + }, + { + "artifactId": "client-content-transfer-rotation-2", + "displayName": "ContentTransferManager.log.2", + "originalPath": "C:\\Windows\\CCM\\Logs\\ContentTransferManager.log.2", + "host": "LAB-CLIENT-01", + "role": "client", + "configmgrVersion": "5.00.9128.1007", + "collectedAtUtc": "2026-07-30T15:00:00Z", + "rotation": { + "kind": "numbered", + "value": 2 + }, + "coverage": "captured", + "encoding": "utf-8" + }, + { + "artifactId": "client-app-enforcement", + "displayName": "AppEnforce.log", + "originalPath": "C:\\Windows\\CCM\\Logs\\AppEnforce.log", + "host": "LAB-CLIENT-01", + "role": "client", + "configmgrVersion": "5.00.9128.1007", + "collectedAtUtc": "2026-07-30T15:00:00Z", + "rotation": { + "kind": "current" + }, + "coverage": "absent", + "encoding": null + }, + { + "artifactId": "client-config-registry", + "displayName": "ClientConfigurationRegistry.json", + "originalPath": null, + "host": "LAB-CLIENT-01", + "role": "client", + "configmgrVersion": "5.00.9128.1007", + "collectedAtUtc": "2026-07-30T15:00:00Z", + "rotation": { + "kind": "current" + }, + "coverage": "accessDenied", + "encoding": null + } +] diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/spine/multiline-policy.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/spine/multiline-policy.log new file mode 100644 index 000000000..2c82c8cb9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/spine/multiline-policy.log @@ -0,0 +1,2 @@ + diff --git a/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs b/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs new file mode 100644 index 000000000..3cc585940 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs @@ -0,0 +1,80 @@ +use cmtraceopen_parser::sccm::client::{ + admit_client_evidence, assess_client_intake, SccmClientCapturedPayload, + SccmClientIntakeArtifact, SccmClientIntakeBundle, +}; +use cmtraceopen_parser::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; +use sha2::{Digest, Sha256}; + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn bytes() -> Vec { + concat!( + "\n" + ) + .as_bytes() + .to_vec() +} + +#[test] +fn public_bytes_only_facade_uses_intake_bound_content_authority() { + let bytes = bytes(); + let expected_length = bytes.len() as u64; + let expected_digest = digest(&bytes); + let bundle = SccmClientIntakeBundle { + artifacts: vec![SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: "fixture-policy-approved".to_owned(), + display_name: "PolicyAgent.log".to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some("synthetic-policy-approved".to_owned()), + rotation_lineage: None, + relative_path: Some("evidence/client-policy-agent/current/PolicyAgent.log".to_owned()), + fragment_complete: Some(true), + declared_byte_length: Some(expected_length), + content_sha256: Some(expected_digest.clone()), + }], + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).expect("public intake is canonical"); + let payload = match SccmClientCapturedPayload::new("fixture-policy-approved", bytes) { + Ok(payload) => payload, + Err(error) => panic!("public payload constructor rejected canonical bytes: {error}"), + }; + + if let Err(error) = admit_client_evidence(&bundle, &assessment, &[payload]) { + panic!("public callers can obtain opaque authority only for intake-bound bytes: {error}"); + } + + let wire = serde_json::to_value(&bundle).expect("bound intake serializes"); + assert!(wire["artifacts"][0]["declaredByteLength"].is_u64()); + assert_eq!( + wire["artifacts"][0]["contentSha256"].as_str().map(str::len), + Some(64) + ); + let round_trip: SccmClientIntakeBundle = + serde_json::from_value(wire).expect("bound intake round trips"); + let projected = assess_client_intake(&round_trip).expect("round trip remains canonical"); + assert_eq!( + projected.physical_artifacts[0].declared_byte_length, + Some(expected_length) + ); + assert_eq!( + projected.physical_artifacts[0].content_sha256.as_deref(), + Some(expected_digest.as_str()) + ); +} 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..baab7ff31 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -0,0 +1,2368 @@ +//! 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 through canonical intake +//! and compares the reducer output against the declared expectations. + +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +use cmtraceopen_parser::sccm::{ + admit_client_evidence, analyze_client_deployment as production_analyze_client_deployment, + assess_client_intake, classify_artifact_name, declared_source_catalog, SccmArtifact, + SccmClientAdmittedEvidence, SccmClientCapturedPayload, SccmClientIntakeArtifact, + SccmClientIntakeBundle, SccmConfidence, SccmCoverageState, SccmDeploymentClassification, + SccmDeploymentConfidence, SccmDeploymentKeyConfidence, SccmDeploymentKeyProfileKind, + SccmDeploymentObservationKeyConfidence, SccmDeploymentPhase, + SccmDeploymentProfileSelectionState, SccmDeploymentState, SccmFindingClass, SccmRole, + SccmRotation, SCCM_DEPLOYMENT_PROFILE_ID, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +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", +]; + +const FULL_OUTPUT_SHA256: [(&str, &str); 12] = [ + ( + "bits-transfer-failure", + "a39eceeb0450a0e73d6cd5ac65e11d5e63ca2f135e3ea72b998ef52c24d9d7fb", + ), + ( + "cache-failure", + "d373bcf0f690f15f810f9a7fb1580b08afab9fcc2a8293aa4d25465c15b4c0b1", + ), + ( + "dependency-failure", + "731432421400d044a28b237f963f2b6845410333279aa0c915e779cd615c5dfa", + ), + ( + "detection-false-negative", + "b9201ff1529ffddd9c4fd506be38b854eeb169d8bd801000165a5ceca8dba0a3", + ), + ( + "dp-content-missing", + "e377a8a760c6c6012f3507bf49ebcdd27613a655c217c697d0f862b6cac6cd22", + ), + ( + "enforcement-exit", + "43eb2e58152fb36132d4ae47b5a1c5b1d231c6ee63e4c1f9737d44f50048e7fc", + ), + ( + "incomplete", + "cee38f7767a1bf75b6abbb91ecb7c47805f5ae0f7936ef98e9cbce9267d4950c", + ), + ( + "location-missing", + "e31da1587a1e580cc666f7cfc3812b5c26d75c39b53415fc3648aed4444c52ec", + ), + ( + "not-targeted", + "45b6bbbcb4999608779a905e9fb0e7f7b6c9fa8027c5cdea3686ed0b20950c88", + ), + ( + "requirements-failure", + "bb56df98c42459b874ffde81cdf1dd2425bf4e6783a03daa28f921917fc82a90", + ), + ( + "rotation-boundary", + "c9d74cbe7d336c92fa07d4a00a257725976341b4bc14c36742430de9ba3c1674", + ), + ( + "success", + "b05f48b884152e71d602070a99cec1152c104799e0792d235a2f9a062fd4eba4", + ), +]; + +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 { + let root = deployment_root().join(scenario); + let manifest = load_json(&root.join("manifest.json")); + let mut value = load_json(&root.join("expected.json")); + let artifact_ids = manifest["artifacts"] + .as_array() + .expect("manifest artifacts") + .iter() + .enumerate() + .map(|(index, artifact)| { + ( + artifact["artifactId"] + .as_str() + .expect("artifactId") + .to_owned(), + format!("fixture-deployment-numbered-{:02}", index + 1), + ) + }) + .collect::>(); + translate_artifact_ids(&mut value, &artifact_ids); + value +} + +fn translate_artifact_ids(value: &mut Value, artifact_ids: &BTreeMap) { + match value { + Value::String(text) => { + for (fixture, admitted) in artifact_ids { + *text = text.replace(fixture, admitted); + } + *text = text.replace("deployment-client-5.00.test-v1", SCCM_DEPLOYMENT_PROFILE_ID); + *text = text.replace("5.00.TEST.", "5.00.9128."); + } + Value::Array(values) => { + for value in values { + translate_artifact_ids(value, artifact_ids); + } + } + Value::Object(fields) => { + for value in fields.values_mut() { + translate_artifact_ids(value, artifact_ids); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn sha256(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn load_admitted(scenario: &str) -> SccmClientAdmittedEvidence { + let scenario_root = deployment_root().join(scenario); + let manifest = load_json(&scenario_root.join("manifest.json")); + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + + for (index, entry) in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .enumerate() + { + 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" => SccmCoverageState::Captured, + "capped" => SccmCoverageState::Capped, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "skipped" => SccmCoverageState::Skipped, + "unsupported" => SccmCoverageState::Unsupported, + "parseFailed" => SccmCoverageState::ParseFailed, + other => panic!("unsupported captureState {other}"), + }; + let rotation = match entry["rotation"]["kind"].as_str() { + Some("current") => SccmRotation::Current, + Some("lo") | Some("loUnderscore") => SccmRotation::LoUnderscore, + other => panic!("unsupported rotation kind {other:?}"), + }; + let artifact_id = format!("fixture-deployment-numbered-{:02}", index + 1); + let basename = entry["originalBasename"] + .as_str() + .expect("originalBasename is a string") + .to_owned(); + let classified = classify_artifact_name(&basename, SccmRole::Client); + if !classified.supported_for_diagnosis || !classified.uses_ccm_records { + continue; + } + let path_fingerprint = entry["pathFingerprint"] + .as_str() + .expect("pathFingerprint is a string") + .to_owned(); + let bytes = (coverage == SccmCoverageState::Captured && fragment_complete).then(|| { + std::fs::read( + scenario_root.join( + entry["relativePath"] + .as_str() + .expect("complete capture has a relative path"), + ), + ) + .expect("declared evidence is readable") + }); + artifacts.push(SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: basename, + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: entry["capturedUtc"].as_str().map(str::to_owned), + rotation, + coverage, + encoding: entry["encoding"].as_str().map(str::to_owned), + }, + path_fingerprint: Some(path_fingerprint.clone()), + rotation_lineage: Some(path_fingerprint), + relative_path: entry["relativePath"].as_str().map(str::to_owned), + fragment_complete: Some(fragment_complete), + declared_byte_length: bytes.as_ref().map(|bytes| bytes.len() as u64), + content_sha256: bytes.as_ref().map(|bytes| sha256(bytes)), + }); + if let Some(bytes) = bytes { + payloads + .push(SccmClientCapturedPayload::new(artifact_id, bytes).expect("bounded payload")); + } + } + + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle) + .unwrap_or_else(|error| panic!("{scenario}: canonical deployment intake: {error}")); + admit_client_evidence(&bundle, &assessment, &payloads) + .unwrap_or_else(|error| panic!("{scenario}: sealed deployment evidence: {error}")) +} + +fn analyze_scenario(scenario: &str) -> cmtraceopen_parser::sccm::SccmDeploymentAnalysis { + analyze_client_deployment(&load_admitted(scenario)) +} + +#[test] +fn committed_corpus_freezes_the_exact_full_public_output() { + let actual = SCENARIOS.map(|scenario| { + let serialized = serde_json::to_vec(&analyze_scenario(scenario)) + .expect("deployment analysis serializes"); + (scenario, sha256(&serialized)) + }); + let expected = FULL_OUTPUT_SHA256.map(|(scenario, digest)| (scenario, digest.to_owned())); + assert_eq!(actual, expected); +} + +fn analyze_client_deployment( + admitted: &SccmClientAdmittedEvidence, +) -> cmtraceopen_parser::sccm::SccmDeploymentAnalysis { + production_analyze_client_deployment(admitted).expect("sealed deployment analysis") +} + +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_scenario(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_scenario(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_PROFILE_ID, + "{label}: extraction profile" + ); + assert_eq!( + declared_key["extractionProfileId"], SCCM_DEPLOYMENT_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_scenario(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 mut produced_spans = produced + .evidence + .iter() + .map(|reference| { + ( + reference.artifact_id.clone(), + reference.line_start, + reference.line_end, + ) + }) + .collect::>(); + let mut 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::>(); + produced_spans.sort(); + declared_spans.sort(); + 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_scenario(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_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_scenario(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::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::Recovered => "recovered", + SccmFindingClass::ContradictoryEvidence => "contradictoryEvidence", + 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_scenario(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_scenario(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_scenario(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_scenario(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_scenario(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 repeated_analysis_of_the_same_sealed_authority_is_deterministic() { + for scenario in SCENARIOS { + let bundle = load_admitted(scenario); + let forward = analyze_client_deployment(&bundle); + assert_eq!( + analyze_client_deployment(&bundle), + forward, + "{scenario}: repeated sealed analysis changed the result" + ); + } +} + +// --------------------------------------------------------------------------- +// 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: format!("fixture-{artifact_id}"), + display_name: basename.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + } +} + +fn record(message: &str, time: &str, component: &str) -> String { + format!( + "\n" + ) +} + +fn deployment_group(basename: &str) -> &'static str { + let source = classify_artifact_name(basename, SccmRole::Client); + match source.logical_name.as_str() { + "appIntentEval" | "appDiscovery" => "client-app-intent", + "appEnforce" => "client-app-enforce", + "cas" | "contentTransferManager" | "dataTransferService" => "client-content", + "stateMessage" => "client-policy-state", + _ => "client-app-intent", + } +} + +fn try_bundle_from( + sources: Vec<(SccmArtifact, String)>, +) -> Result { + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + for (index, (mut artifact, content)) in sources.into_iter().enumerate() { + let bytes = content.into_bytes(); + let artifact_id = format!("fixture-deployment-numbered-{:02}", index + 1); + artifact.artifact_id = artifact_id.clone(); + let basename = artifact.display_name.clone(); + artifact.collected_at_utc = Some("2026-07-30T00:00:00Z".to_owned()); + artifact.coverage = SccmCoverageState::Captured; + let rotation_segment = match artifact.rotation { + SccmRotation::Current => "current".to_owned(), + SccmRotation::LoUnderscore => "lo".to_owned(), + SccmRotation::Numbered(number) => format!("numbered-{number}"), + ref other => panic!("unsupported custom deployment rotation {other:?}"), + }; + artifacts.push(SccmClientIntakeArtifact { + artifact, + path_fingerprint: Some(format!("synthetic:deployment:numbered:{:02}", index + 1)), + rotation_lineage: None, + relative_path: Some(format!( + "evidence/{}/{}/{}", + deployment_group(&basename), + rotation_segment, + basename + )), + fragment_complete: Some(true), + declared_byte_length: Some(bytes.len() as u64), + content_sha256: Some(sha256(&bytes)), + }); + payloads.push( + SccmClientCapturedPayload::new(artifact_id, bytes) + .map_err(|error| error.to_string())?, + ); + } + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).map_err(|error| error.to_string())?; + admit_client_evidence(&bundle, &assessment, &payloads).map_err(|error| error.to_string()) +} + +fn bundle_from(sources: Vec<(SccmArtifact, String)>) -> SccmClientAdmittedEvidence { + try_bundle_from(sources).expect("sealed synthetic deployment input") +} + +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 canonical_client_admission_rejects_an_artifact_from_another_role() { + 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(); + + assert!( + try_bundle_from(vec![ + (client, intent_content()), + (management_point, intent_content()), + ]) + .is_err(), + "mixed-role authority must fail closed before deployment analysis" + ); +} + +#[test] +fn reducer_fails_closed_on_admitted_unorderable_terminal_records() { + 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 = try_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, + ), + ]) + .expect("coherent unorderable timestamps are admitted"); + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!(transaction.phase, SccmDeploymentPhase::Transfer); + assert_eq!(transaction.state, SccmDeploymentState::InsufficientEvidence); + assert_eq!( + transaction.classification, + SccmDeploymentClassification::Symptom + ); + assert_eq!(transaction.confidence, SccmDeploymentConfidence::Low); + assert_eq!( + transaction.last_successful_phase, + Some(SccmDeploymentPhase::LocateContent) + ); + assert!(analysis.findings.iter().all(|finding| { + finding.finding.terminal_evidence.is_empty() + && finding + .finding + .finding_id + .starts_with("deployment-chronology-uncertain") + })); +} + +#[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 canonical_admission_rejects_an_unprofiled_source_version() { + let mut artifact = client_artifact("synthetic-intent", "AppIntentEval.log"); + artifact.configmgr_version = Some("5.00.PROD.9128".to_owned()); + assert!( + try_bundle_from(vec![(artifact, intent_content())]).is_err(), + "an unregistered version must fail before reducer authority exists" + ); +} + +#[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_scenario(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", +]; + +const OBSERVED_FAMILY_SCENARIOS: [&str; 8] = [ + "bits-transfer-failure", + "cache-failure", + "detection-false-negative", + "dp-content-missing", + "enforcement-exit", + "incomplete", + "rotation-boundary", + "success", +]; + +#[test] +fn the_selected_extraction_profile_reports_what_the_bundle_validated() { + for scenario in SCENARIOS { + let analysis = analyze_scenario(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 canonical_admission_rejects_a_physical_fragment_inside_a_captured_artifact() { + let content = format!( + "{}Requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}\n", + intent_record() + ); + assert!( + try_bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )]) + .is_err(), + "incomplete CCM framing must fail before reducer authority exists" + ); +} + +#[test] +fn canonical_admission_rejects_a_terminal_physical_fragment() { + let content = format!( + "{}Requirements terminal failure assignmentId={ASSIGNMENT} ciId={CI} requirementId=REQ-TEST-901 terminal=true\n", + intent_record() + ); + assert!( + try_bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )]) + .is_err(), + "a terminal-looking fragment must fail before reducer authority exists" + ); +} + +#[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" + ); + assert_eq!( + transaction.key.confidence, + SccmDeploymentKeyConfidence::Candidate, + "{label}: conflicting topology cannot remain exact" + ); + assert_eq!(transaction.state, SccmDeploymentState::InsufficientEvidence); + assert_eq!( + transaction.classification, + SccmDeploymentClassification::Symptom + ); + assert_eq!(transaction.confidence, SccmDeploymentConfidence::Low); + } +} + +#[test] +fn equal_time_transfer_success_and_failure_are_not_a_confirmed_failure() { + let terminal_failure = record( + &format!( + "Transfer terminal failure assignmentId={ASSIGNMENT} contentId={CONTENT} bitsJobId={BITS_JOB} errorCode=0x80070020 terminal=true" + ), + "05:00:04.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", "DataTransferService.log"), + transfer_content("05:00:03.000+000", "05:00:04.000+000"), + ), + ( + rotated_artifact( + "synthetic-transfer-rotated", + "ContentTransferManager.log.1", + SccmRotation::Numbered(1), + ), + terminal_failure, + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!(transaction.phase, SccmDeploymentPhase::Transfer); + assert_eq!(transaction.state, SccmDeploymentState::InsufficientEvidence); + assert_eq!( + transaction.classification, + SccmDeploymentClassification::Symptom + ); + assert_eq!(transaction.confidence, SccmDeploymentConfidence::Low); + assert!(analysis.findings.iter().all(|finding| { + finding.finding.class != SccmFindingClass::ConfirmedFailure + && finding.finding.terminal_evidence.is_empty() + })); +} + +#[test] +fn later_ordered_success_recovers_early_intent_and_requirement_adverse_outcomes() { + let cases = [ + ( + "intent", + format!( + "{}{}", + record( + &format!("SYNTHETIC FIXTURE deployment explicitly not targeted assignmentId={ASSIGNMENT} ciId={CI} state=notApplicable terminal=true"), + "05:00:00.000+000", + "AppIntentEval", + ), + record( + &format!("SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted"), + "05:00:01.000+000", + "AppIntentEval", + ), + ), + ), + ( + "requirements", + format!( + "{}{}{}", + intent_record(), + record( + &format!("Requirements terminal failure assignmentId={ASSIGNMENT} ciId={CI} requirementId=REQ-RECOVERY terminal=true"), + "05:00:01.000+000", + "AppIntentEval", + ), + record( + &format!("Requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}"), + "05:00:02.000+000", + "AppIntentEval", + ), + ), + ), + ( + "dependency", + format!( + "{}{}{}", + intent_record(), + record( + &format!("Dependency terminal failure assignmentId={ASSIGNMENT} ciId={CI} dependencyCiId={OTHER_CI} terminal=true"), + "05:00:01.000+000", + "AppIntentEval", + ), + record( + &format!("Requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}"), + "05:00:02.000+000", + "AppIntentEval", + ), + ), + ), + ]; + + for (label, content) in cases { + let analysis = analyze_client_deployment(&bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )])); + let transaction = only_transaction(&analysis); + assert_ne!( + transaction.state, + SccmDeploymentState::NotTargeted, + "{label}" + ); + assert_ne!(transaction.state, SccmDeploymentState::Failed, "{label}"); + assert!(analysis + .findings + .iter() + .all(|finding| finding.finding.class != SccmFindingClass::ConfirmedFailure)); + } +} + +#[test] +fn later_ordered_detection_success_recovers_an_earlier_mismatch() { + let enforce = record( + &format!("SYNTHETIC FIXTURE deployment success enforcement completed assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode=0 terminal=true"), + "05:00:06.000+000", + "AppEnforce", + ); + let detection = format!( + "{}{}", + record( + &format!("SYNTHETIC FIXTURE deployment detection false negative assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} detected=false"), + "05:00:07.000+000", + "AppDiscovery", + ), + record( + &format!("SYNTHETIC FIXTURE deployment success detected assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} detected=true"), + "05:00:08.000+000", + "AppDiscovery", + ), + ); + let analysis = analyze_client_deployment(&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"), + enforce, + ), + ( + client_artifact("synthetic-detect", "AppDiscovery.log"), + detection, + ), + ])); + let transaction = only_transaction(&analysis); + assert_eq!(transaction.phase, SccmDeploymentPhase::Report); + assert_ne!(transaction.state, SccmDeploymentState::DetectionMismatch); + assert!(analysis + .findings + .iter() + .all(|finding| finding.finding.finding_id != "sccm.client.deployment.detection-mismatch")); +} + +#[test] +fn later_ordered_adverse_outcomes_remain_authoritative() { + let intent = format!( + "{}{}", + intent_record(), + record( + &format!("SYNTHETIC FIXTURE deployment explicitly not targeted assignmentId={ASSIGNMENT} ciId={CI} state=notApplicable terminal=true"), + "05:00:01.000+000", + "AppIntentEval", + ), + ); + let analysis = analyze_client_deployment(&bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent, + )])); + assert_eq!( + only_transaction(&analysis).state, + SccmDeploymentState::NotTargeted + ); + + for (label, adverse) in [ + ( + "requirements", + format!("Requirements terminal failure assignmentId={ASSIGNMENT} ciId={CI} requirementId=REQ-LATEST terminal=true"), + ), + ( + "dependency", + format!("Dependency terminal failure assignmentId={ASSIGNMENT} ciId={CI} dependencyCiId={OTHER_CI} terminal=true"), + ), + ] { + let content = format!( + "{}{}{}", + intent_record(), + record( + &format!("Requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}"), + "05:00:01.000+000", + "AppIntentEval", + ), + record(&adverse, "05:00:02.000+000", "AppIntentEval"), + ); + let analysis = analyze_client_deployment(&bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )])); + let transaction = only_transaction(&analysis); + assert_eq!(transaction.state, SccmDeploymentState::Failed, "{label}"); + assert_eq!( + transaction.classification, + SccmDeploymentClassification::ConfirmedFailure, + "{label}" + ); + } +} + +#[test] +fn a_captured_source_missing_a_required_record_is_not_absent_coverage() { + let intent_only = record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_only, + )]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!(transaction.phase, SccmDeploymentPhase::Requirements); + assert_eq!(transaction.state, SccmDeploymentState::InsufficientEvidence); + assert!(analysis.coverage.iter().any(|row| { + row.logical_artifact_id == "client-app-intent" && row.state == SccmCoverageState::Captured + })); + assert!(analysis + .findings + .iter() + .flat_map(|finding| finding.finding.coverage_gaps.iter()) + .all(|gap| { + gap.artifact_id != "client-app-intent" && gap.coverage != SccmCoverageState::Absent + })); +} + +#[test] +fn incomplete_captured_rotations_preserve_truthful_capture_provenance() { + let analysis = analyze_scenario("rotation-boundary"); + let coverage = analysis + .coverage + .iter() + .find(|row| row.logical_artifact_id == "client-content") + .expect("content coverage"); + assert_eq!(coverage.state, SccmCoverageState::Captured); + assert!(!coverage.capture_complete); + assert_eq!( + coverage.artifact_ids, + [ + "fixture-deployment-numbered-01".to_owned(), + "fixture-deployment-numbered-02".to_owned(), + ] + ); + let transaction = only_transaction(&analysis); + assert_eq!(transaction.state, SccmDeploymentState::InsufficientEvidence); + assert_eq!( + transaction.classification, + SccmDeploymentClassification::Symptom + ); + assert!(transaction.coverage_gap_artifact_ids.is_empty()); +} + +#[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 reducer_fails_closed_on_admitted_cross_phase_unorderable_timestamps() { + 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 = try_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, + ), + ]) + .expect("coherent unorderable timestamps are admitted"); + let analysis = analyze_client_deployment(&bundle); + assert_eq!(analysis.transactions.len(), 2); + assert!(analysis.transactions.iter().all(|transaction| { + transaction.state == SccmDeploymentState::InsufficientEvidence + && transaction.classification == SccmDeploymentClassification::Symptom + && transaction.confidence == SccmDeploymentConfidence::Low + })); + assert!(analysis.transactions.iter().any(|transaction| { + transaction.phase == SccmDeploymentPhase::Requirements + && transaction.last_successful_phase == Some(SccmDeploymentPhase::Intent) + })); + assert!(analysis.transactions.iter().any(|transaction| { + transaction.phase == SccmDeploymentPhase::Enforce + && transaction.last_successful_phase == Some(SccmDeploymentPhase::Cache) + })); + assert_eq!(analysis.findings.len(), 2); + assert!(analysis.findings.iter().all(|finding| { + finding.finding.terminal_evidence.is_empty() + && finding + .finding + .finding_id + .starts_with("deployment-chronology-uncertain") + })); +} + +#[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![ + "fixture-deployment-numbered-04".to_owned(), + "fixture-deployment-numbered-05".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_scenario(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()); + assert!( + try_bundle_from(vec![(unprofiled, intent_content())]).is_err(), + "an unregistered profile cannot create deployment analysis authority" + ); +} + +#[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, "fixture-deployment-numbered-02", + "{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 reducer_suppresses_an_admitted_unorderable_repeated_content_request() { + 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 = try_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"), + ), + ]) + .expect("coherent unorderable timestamps are admitted"); + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!(transaction.phase, SccmDeploymentPhase::Transfer); + assert_eq!(transaction.state, SccmDeploymentState::InsufficientEvidence); + assert_eq!( + transaction.classification, + SccmDeploymentClassification::Symptom + ); + assert_eq!(transaction.confidence, SccmDeploymentConfidence::Low); + assert!(transaction.counterpart_ready_fact.is_none()); + assert!(!analysis.correlation_handoff.emitted_counterpart_ready_fact); + assert!(analysis + .findings + .iter() + .all(|finding| finding.finding.terminal_evidence.is_empty())); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_health.rs b/crates/cmtraceopen-parser/tests/sccm_client_health.rs new file mode 100644 index 000000000..54b19dfae --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_health.rs @@ -0,0 +1,971 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, +}; + +use cmtraceopen_parser::sccm::client::{ + admit_client_evidence, analyze_client_health, assess_client_intake, SccmClientAdmittedEvidence, + SccmClientCapturedPayload, SccmClientHealthAnalysis, SccmClientHealthHopState, + SccmClientHealthPhase, SccmClientIntakeArtifact, SccmClientIntakeBundle, +}; +use cmtraceopen_parser::sccm::{ + SccmArtifact, SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const FIXTURE_ROOT: &str = "tests/fixtures/sccm/client/health"; +const SCENARIOS: &[&str] = &[ + "authentication-failure", + "boundary-location-failure", + "contradictory", + "identity-failure", + "incomplete", + "malformed", + "no-site-or-mp", + "rotation-boundary", + "setup-failure", + "success", + "transport-failure", +]; + +struct FixtureAdmission { + admitted: SccmClientAdmittedEvidence, + artifact_ids: BTreeMap, +} + +fn fixture_directory(scenario: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join(FIXTURE_ROOT) + .join(scenario) +} + +fn load_json(path: &Path) -> Value { + serde_json::from_slice( + &fs::read(path).unwrap_or_else(|error| panic!("{} is readable: {error}", path.display())), + ) + .unwrap_or_else(|error| panic!("{} is valid JSON: {error}", path.display())) +} + +fn coverage(value: &str) -> SccmCoverageState { + match value { + "captured" => SccmCoverageState::Captured, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "capped" => SccmCoverageState::Capped, + "skipped" => SccmCoverageState::Skipped, + "unsupported" => SccmCoverageState::Unsupported, + "parseFailed" => SccmCoverageState::ParseFailed, + other => panic!("unsupported health fixture coverage {other}"), + } +} + +fn rotation(value: &str) -> SccmRotation { + match value { + "current" => SccmRotation::Current, + "lo" => SccmRotation::LoUnderscore, + other => panic!("unsupported health fixture rotation {other}"), + } +} + +fn admit_fixture(scenario: &str) -> Result { + admit_fixture_after(scenario, |_, _| {}) +} + +fn admit_fixture_after( + scenario: &str, + mut mutate_payload: impl FnMut(&str, &mut Vec), +) -> Result { + let root = fixture_directory(scenario); + let manifest = load_json(&root.join("manifest.json")); + assert_eq!( + manifest + .as_object() + .expect("health manifest object") + .keys() + .map(String::as_str) + .collect::>(), + BTreeSet::from([ + "artifacts", + "sccmManifestVersion", + "scenario", + "syntheticFixture", + ]), + "{scenario}: production fixture manifest schema" + ); + assert_eq!(manifest["sccmManifestVersion"], 1); + assert_eq!(manifest["syntheticFixture"], true); + assert_eq!(manifest["scenario"], scenario); + let declared = manifest["artifacts"] + .as_array() + .ok_or_else(|| "manifest artifacts missing".to_owned())?; + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + let mut artifact_ids = BTreeMap::new(); + + for (index, item) in declared.iter().enumerate() { + assert_eq!( + item.as_object() + .expect("health manifest artifact object") + .keys() + .map(String::as_str) + .collect::>(), + BTreeSet::from([ + "artifactId", + "bytesCopied", + "captureState", + "capturedUtc", + "encoding", + "originalBasename", + "pathFingerprint", + "relativePath", + "role", + "rotation", + "sourceVersion", + ]), + "{scenario}: production fixture artifact schema" + ); + assert_eq!( + item["rotation"] + .as_object() + .expect("health manifest rotation object") + .keys() + .map(String::as_str) + .collect::>(), + BTreeSet::from(["fragmentComplete", "kind"]), + "{scenario}: production fixture rotation schema" + ); + assert_eq!(item["role"], "client", "{scenario}: exact client role"); + let preparation_id = item["artifactId"] + .as_str() + .ok_or_else(|| "artifactId missing".to_owned())?; + let artifact_id = format!("fixture-health-numbered-{:02}", index + 1); + artifact_ids.insert(preparation_id.to_owned(), artifact_id.clone()); + let source_coverage = coverage( + item["captureState"] + .as_str() + .ok_or_else(|| "captureState missing".to_owned())?, + ); + let fragment_complete = item["rotation"]["fragmentComplete"] + .as_bool() + .unwrap_or(false); + let committed_bytes = item["relativePath"] + .as_str() + .map(|relative| fs::read(root.join(relative)).map_err(|error| error.to_string())) + .transpose()?; + assert_eq!( + item["bytesCopied"].as_u64(), + Some( + committed_bytes + .as_ref() + .map_or(0, |bytes| bytes.len() as u64) + ), + "{scenario}: exact committed fixture byte count" + ); + let mut payload_bytes = (source_coverage == SccmCoverageState::Captured + && fragment_complete) + .then(|| { + committed_bytes + .clone() + .ok_or_else(|| "complete capture has no relativePath".to_owned()) + }) + .transpose()?; + if let Some(bytes) = &mut payload_bytes { + mutate_payload(preparation_id, bytes); + } + artifacts.push(SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: item["originalBasename"] + .as_str() + .ok_or_else(|| "originalBasename missing".to_owned())? + .to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: item["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: item["capturedUtc"].as_str().map(str::to_owned), + rotation: rotation( + item["rotation"]["kind"] + .as_str() + .ok_or_else(|| "rotation kind missing".to_owned())?, + ), + coverage: source_coverage, + encoding: item["encoding"].as_str().map(str::to_owned), + }, + path_fingerprint: item["pathFingerprint"].as_str().map(str::to_owned), + rotation_lineage: None, + relative_path: item["relativePath"].as_str().map(str::to_owned), + fragment_complete: Some(fragment_complete), + declared_byte_length: payload_bytes.as_ref().map(|bytes| bytes.len() as u64), + content_sha256: payload_bytes.as_ref().map(|bytes| digest(bytes)), + }); + if let Some(bytes) = payload_bytes { + payloads.push( + SccmClientCapturedPayload::new(artifact_id, bytes) + .map_err(|error| error.to_string())?, + ); + } + } + + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).map_err(|error| error.to_string())?; + let admitted = admit_client_evidence(&bundle, &assessment, &payloads) + .map_err(|error| error.to_string())?; + Ok(FixtureAdmission { + admitted, + artifact_ids, + }) +} + +fn analyze_fixture(scenario: &str) -> SccmClientHealthAnalysis { + let fixture = admit_fixture(scenario) + .unwrap_or_else(|error| panic!("{scenario}: fixture admission failed: {error}")); + analyze_client_health(&fixture.admitted) + .unwrap_or_else(|error| panic!("{scenario}: health analysis failed: {error}")) +} + +fn analyze_success_regression(name: &str) -> (SccmClientHealthAnalysis, BTreeMap) { + let fixture = admit_fixture_after("success", |artifact_id, bytes| { + let content = std::str::from_utf8(bytes).expect("health fixture is UTF-8"); + let mutated = match (name, artifact_id) { + ("service-before-install", "health-success-evaluation-current") => { + content.replace("01:00:01.000+000", "00:59:59.000+000") + } + ("cross-client-management-point", "health-success-location-services-current") => { + content.replace( + "Phase=managementPointLocation Disposition=succeeded Terminal=true SiteCode=LAB", + "Phase=managementPointLocation Disposition=succeeded Terminal=true ClientGuid=22222222-2222-2222-2222-222222222222 SiteCode=LAB", + ) + } + ( + "equal-time-clientless-mp-host-conflict", + "health-success-location-services-current", + ) => format!( + "{content}\n" + ), + ( + "equal-time-clientless-transport-request-conflict", + "health-success-location-services-current", + ) => format!( + "{content}\n" + ), + ( + "equal-time-service-guid-conflict", + "health-success-evaluation-current", + ) => format!( + "{content}\n" + ), + ( + "equal-time-assignment-guid-conflict", + "health-success-location-services-current", + ) => format!( + "{content}\n" + ), + ( + "equal-time-clientless-mp-site-conflict", + "health-success-location-services-current", + ) => format!( + "{content}\n" + ), + ( + "equal-time-clientless-transport-host-conflict", + "health-success-location-services-current", + ) => format!( + "{content}\n" + ), + ("equal-time-identical-tuples", "health-success-evaluation-current") => format!( + "{content}\n" + ), + ("equal-time-identical-tuples", "health-success-location-services-current") => format!( + "{content}\n\n\n" + ), + ("service-retry-after-success", "health-success-evaluation-current") => format!( + "{content}\n" + ), + ("service-equal-time-retry-after-success", "health-success-evaluation-current") => { + format!( + "{content}\n" + ) + } + ("repair-retry-after-install-success", "health-success-ccmsetup-current") => format!( + "{content}\n" + ), + ("repair-retry-terminal-after-pending", "health-success-ccmsetup-current") => format!( + "{content}\n\n" + ), + _ => return, + }; + *bytes = mutated.into_bytes(); + }) + .unwrap_or_else(|error| panic!("{name}: sealed fixture admission failed: {error}")); + let analysis = analyze_client_health(&fixture.admitted) + .unwrap_or_else(|error| panic!("{name}: health analysis failed: {error}")); + (analysis, fixture.artifact_ids) +} + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn translate_artifact_ids(value: &mut Value, artifact_ids: &BTreeMap) { + match value { + Value::String(text) => { + let mut translations = artifact_ids.iter().collect::>(); + translations.sort_by_key(|(_, admitted)| std::cmp::Reverse(admitted.len())); + for (fixture, admitted) in translations { + *text = text.replace(admitted, fixture); + } + } + Value::Array(values) => { + for value in values { + translate_artifact_ids(value, artifact_ids); + } + } + Value::Object(fields) => { + for value in fields.values_mut() { + translate_artifact_ids(value, artifact_ids); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn canonicalize_json(value: Value) -> Value { + match value { + Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_json).collect()), + Value::Object(fields) => { + let mut entries = fields.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key, canonicalize_json(value))) + .collect(), + ) + } + scalar => scalar, + } +} + +fn normalized_output( + analysis: &SccmClientHealthAnalysis, + artifact_ids: &BTreeMap, +) -> Value { + let mut normalized = serde_json::to_value(analysis).expect("health analysis serializes"); + translate_artifact_ids(&mut normalized, artifact_ids); + canonicalize_json(normalized) +} + +fn admitted_record(phase: &str) -> SccmClientAdmittedEvidence { + let artifact_id = "fixture-policy-agent"; + let message = format!( + "Family=health Phase={phase} Disposition=succeeded Terminal=true ClientGuid=11111111-1111-1111-1111-111111111111" + ); + let bytes = format!( + "\n" + ) + .into_bytes(); + let bundle = SccmClientIntakeBundle { + artifacts: vec![ + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: "ccmsetup.log".to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some("synthetic-policy-agent".to_owned()), + rotation_lineage: None, + relative_path: Some("evidence/client-ccmsetup/current/ccmsetup.log".to_owned()), + fragment_complete: Some(true), + declared_byte_length: Some(bytes.len() as u64), + content_sha256: Some(digest(&bytes)), + }, + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: "fixture-health-numbered-02".to_owned(), + display_name: "CcmEval.log".to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:01Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Absent, + encoding: None, + }, + path_fingerprint: Some( + "synthetic-candidate-health-success-evaluation-absent".to_owned(), + ), + rotation_lineage: None, + relative_path: None, + fragment_complete: Some(false), + declared_byte_length: None, + content_sha256: None, + }, + ], + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).expect("operation bundle intake"); + admit_client_evidence( + &bundle, + &assessment, + &[SccmClientCapturedPayload::new(artifact_id, bytes).expect("operation payload")], + ) + .expect("operation evidence admission") +} + +#[test] +fn lifecycle_operations_are_classified_from_sealed_profiled_records() { + for (name, expected) in [ + ("install", SccmClientHealthPhase::Install), + ("upgrade", SccmClientHealthPhase::Upgrade), + ("repair", SccmClientHealthPhase::Repair), + ("removal", SccmClientHealthPhase::Removal), + ] { + let analysis = analyze_client_health(&admitted_record(name)).expect("health analysis"); + assert_eq!(analysis.lifecycle_phase, Some(expected)); + assert_eq!(analysis.last_confirmed_successful_phase, Some(expected)); + assert_eq!(analysis.hops[0].phase, expected); + assert_eq!(analysis.hops[0].state, SccmClientHealthHopState::Succeeded); + } +} + +#[test] +fn success_fixture_confirms_every_post_install_hop() { + let analysis = analyze_fixture("success"); + assert!( + analysis.findings.is_empty(), + "{}", + serde_json::to_string_pretty(&analysis).expect("debug analysis") + ); + assert_eq!( + analysis.lifecycle_phase, + Some(SccmClientHealthPhase::Install) + ); + assert_eq!( + analysis.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::Transport) + ); + assert_eq!( + analysis + .hops + .iter() + .map(|hop| hop.phase) + .collect::>(), + [ + SccmClientHealthPhase::Install, + SccmClientHealthPhase::Service, + SccmClientHealthPhase::ClientHealth, + SccmClientHealthPhase::Reboot, + SccmClientHealthPhase::Identity, + SccmClientHealthPhase::Authentication, + SccmClientHealthPhase::Assignment, + SccmClientHealthPhase::Boundary, + SccmClientHealthPhase::ManagementPointLocation, + SccmClientHealthPhase::Transport, + ] + ); +} + +#[test] +fn post_lifecycle_phases_require_strict_monotonic_time() { + let (analysis, _) = analyze_success_regression("service-before-install"); + assert_eq!( + analysis + .hops + .iter() + .map(|hop| hop.phase) + .collect::>(), + vec![SccmClientHealthPhase::Install] + ); + assert_eq!( + analysis.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::Install) + ); + assert_eq!(analysis.findings.len(), 1); + assert_eq!( + analysis.findings[0].health_phase, + SccmClientHealthPhase::Service + ); +} + +#[test] +fn newest_attempt_controls_lifecycle_and_service_resolution() { + let (service_retry, _) = analyze_success_regression("service-retry-after-success"); + assert_eq!( + service_retry.lifecycle_phase, + Some(SccmClientHealthPhase::Install) + ); + assert_eq!( + service_retry.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::Install) + ); + assert_eq!( + service_retry.hops.last().map(|hop| (hop.phase, hop.state)), + Some(( + SccmClientHealthPhase::Service, + SccmClientHealthHopState::Pending + )) + ); + + let (repair_retry, _) = analyze_success_regression("repair-retry-after-install-success"); + assert_eq!( + repair_retry.lifecycle_phase, + Some(SccmClientHealthPhase::Repair) + ); + assert_eq!(repair_retry.last_confirmed_successful_phase, None); + assert_eq!( + repair_retry.hops.last().map(|hop| (hop.phase, hop.state)), + Some(( + SccmClientHealthPhase::Repair, + SccmClientHealthHopState::Pending + )) + ); + + let (resolved_retry, _) = analyze_success_regression("repair-retry-terminal-after-pending"); + assert_eq!( + resolved_retry.lifecycle_phase, + Some(SccmClientHealthPhase::Repair) + ); + assert_eq!( + resolved_retry.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::Transport) + ); + assert_eq!( + resolved_retry + .hops + .first() + .map(|hop| (hop.phase, hop.state)), + Some(( + SccmClientHealthPhase::Repair, + SccmClientHealthHopState::Succeeded + )) + ); +} + +#[test] +fn equal_time_terminal_and_retry_evidence_fails_closed() { + let (analysis, _) = analyze_success_regression("service-equal-time-retry-after-success"); + assert_eq!( + analysis.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::Install) + ); + assert_eq!( + analysis.hops.last().map(|hop| (hop.phase, hop.state)), + Some(( + SccmClientHealthPhase::Service, + SccmClientHealthHopState::Contradictory + )) + ); + assert_eq!(analysis.hops.last().expect("service hop").evidence.len(), 2); + assert_eq!(analysis.findings[0].class, SccmFindingClass::Symptom); +} + +#[test] +fn management_point_identity_cannot_cross_clients_on_a_shared_site() { + let (analysis, _) = analyze_success_regression("cross-client-management-point"); + assert_eq!( + analysis.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::Boundary) + ); + assert_eq!(analysis.findings.len(), 1); + assert_eq!( + analysis.findings[0].health_phase, + SccmClientHealthPhase::ManagementPointLocation + ); + assert!(analysis + .hops + .iter() + .all(|hop| hop.phase != SccmClientHealthPhase::ManagementPointLocation)); +} + +#[test] +fn equal_time_management_point_hosts_are_contradictory() { + let (analysis, _) = analyze_success_regression("equal-time-clientless-mp-host-conflict"); + assert_eq!( + analysis.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::Boundary) + ); + assert_eq!( + analysis.hops.last().map(|hop| (hop.phase, hop.state)), + Some(( + SccmClientHealthPhase::ManagementPointLocation, + SccmClientHealthHopState::Contradictory + )) + ); + assert_eq!( + analysis + .hops + .last() + .expect("management point hop") + .evidence + .len(), + 2 + ); + assert_eq!( + analysis.findings[0].health_phase, + SccmClientHealthPhase::ManagementPointLocation + ); +} + +#[test] +fn equal_time_transport_requests_are_contradictory() { + let (analysis, _) = + analyze_success_regression("equal-time-clientless-transport-request-conflict"); + assert_eq!( + analysis.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::ManagementPointLocation) + ); + assert_eq!( + analysis.hops.last().map(|hop| (hop.phase, hop.state)), + Some(( + SccmClientHealthPhase::Transport, + SccmClientHealthHopState::Contradictory + )) + ); + assert_eq!( + analysis.hops.last().expect("transport hop").evidence.len(), + 2 + ); + assert_eq!( + analysis.findings[0].health_phase, + SccmClientHealthPhase::Transport + ); +} + +#[test] +fn equal_time_conflicts_hidden_by_chain_coordinates_are_contradictory() { + for (scenario, phase, last_success) in [ + ( + "equal-time-service-guid-conflict", + SccmClientHealthPhase::Service, + SccmClientHealthPhase::Install, + ), + ( + "equal-time-assignment-guid-conflict", + SccmClientHealthPhase::Assignment, + SccmClientHealthPhase::Authentication, + ), + ( + "equal-time-clientless-mp-site-conflict", + SccmClientHealthPhase::ManagementPointLocation, + SccmClientHealthPhase::Boundary, + ), + ( + "equal-time-clientless-transport-host-conflict", + SccmClientHealthPhase::Transport, + SccmClientHealthPhase::ManagementPointLocation, + ), + ] { + let (analysis, _) = analyze_success_regression(scenario); + assert_eq!( + analysis.last_confirmed_successful_phase, + Some(last_success), + "{scenario}" + ); + assert_eq!( + analysis.hops.last().map(|hop| (hop.phase, hop.state)), + Some((phase, SccmClientHealthHopState::Contradictory)), + "{scenario}" + ); + assert_eq!( + analysis + .hops + .last() + .expect("contradictory hop") + .evidence + .len(), + 2, + "{scenario}" + ); + } +} + +#[test] +fn equal_time_identical_phase_tuples_still_resolve() { + let (analysis, _) = analyze_success_regression("equal-time-identical-tuples"); + assert_eq!( + analysis.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::Transport) + ); + assert!(analysis + .hops + .iter() + .all(|hop| hop.state == SccmClientHealthHopState::Succeeded)); + assert!(analysis.findings.is_empty()); +} + +#[test] +fn sealed_admission_regressions_match_exact_full_output_oracles() { + let oracle_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/client/health-regression-oracles"); + for name in [ + "service-before-install", + "cross-client-management-point", + "service-retry-after-success", + "repair-retry-after-install-success", + "repair-retry-terminal-after-pending", + ] { + let (analysis, artifact_ids) = analyze_success_regression(name); + let actual = normalized_output(&analysis, &artifact_ids); + let path = oracle_root.join(format!("{name}.json")); + assert_eq!(actual, load_json(&path), "{name}: exact full output"); + } +} + +#[test] +fn equal_time_correlation_regressions_match_exact_full_output_oracles() { + for (name, expected_digest) in [ + ( + "equal-time-clientless-mp-host-conflict", + "6f15ed4df3c1cf2827a453a13828c53ac7aa2d6d667cc69fecc60fcad12645aa", + ), + ( + "equal-time-clientless-transport-request-conflict", + "88befd8cf5e8cd5d3e0e8af90a2d4ca7c636d4db3c54503e5b4ab8825262609b", + ), + ( + "equal-time-service-guid-conflict", + "6d2679fba3f3520969ecce84e56cb29705a3dd0e4cc93d09f58fc014d4607c12", + ), + ( + "equal-time-assignment-guid-conflict", + "19e6ca3aa338a92b43c3fe0a80dee9877a5aaa17a622b62596db9caba4420acf", + ), + ( + "equal-time-clientless-mp-site-conflict", + "6f15ed4df3c1cf2827a453a13828c53ac7aa2d6d667cc69fecc60fcad12645aa", + ), + ( + "equal-time-clientless-transport-host-conflict", + "88befd8cf5e8cd5d3e0e8af90a2d4ca7c636d4db3c54503e5b4ab8825262609b", + ), + ( + "equal-time-identical-tuples", + "95ceca4807c1718c1b718a262a483b71723ba0579911b2a00182a4e6c6f40606", + ), + ] { + let (analysis, artifact_ids) = analyze_success_regression(name); + let normalized = normalized_output(&analysis, &artifact_ids); + assert_eq!( + digest(&serde_json::to_vec(&normalized).expect("oracle serializes")), + expected_digest, + "{name}: exact full output" + ); + } +} + +#[test] +fn terminal_and_incomplete_stops_are_conservative_and_request_one_exact_artifact() { + for (scenario, phase, class, last_success, logical_id) in [ + ( + "setup-failure", + SccmClientHealthPhase::Install, + SccmFindingClass::ConfirmedFailure, + None, + "ccmSetup", + ), + ( + "identity-failure", + SccmClientHealthPhase::Identity, + SccmFindingClass::ConfirmedFailure, + Some(SccmClientHealthPhase::Reboot), + "clientIdManagerStartup", + ), + ( + "authentication-failure", + SccmClientHealthPhase::Authentication, + SccmFindingClass::ConfirmedFailure, + Some(SccmClientHealthPhase::Identity), + "clientIdManagerStartup", + ), + ( + "boundary-location-failure", + SccmClientHealthPhase::Boundary, + SccmFindingClass::ConfirmedFailure, + Some(SccmClientHealthPhase::Assignment), + "locationServices", + ), + ( + "transport-failure", + SccmClientHealthPhase::Transport, + SccmFindingClass::ConfirmedFailure, + Some(SccmClientHealthPhase::ManagementPointLocation), + "locationServices", + ), + ( + "incomplete", + SccmClientHealthPhase::Identity, + SccmFindingClass::InsufficientEvidence, + Some(SccmClientHealthPhase::Reboot), + "clientIdManagerStartup", + ), + ( + "malformed", + SccmClientHealthPhase::Service, + SccmFindingClass::InsufficientEvidence, + Some(SccmClientHealthPhase::Install), + "ccmEval", + ), + ( + "rotation-boundary", + SccmClientHealthPhase::Install, + SccmFindingClass::InsufficientEvidence, + None, + "ccmSetup", + ), + ] { + let analysis = analyze_fixture(scenario); + assert_eq!(analysis.findings.len(), 1, "{scenario}"); + let finding = &analysis.findings[0]; + assert_eq!(finding.health_phase, phase, "{scenario}"); + assert_eq!(finding.class, class, "{scenario}"); + assert_eq!(finding.last_confirmed_successful_phase, last_success); + assert_eq!(finding.next_artifacts.len(), 1); + assert_eq!(finding.next_artifacts[0].logical_id, logical_id); + if class == SccmFindingClass::ConfirmedFailure { + assert!(!finding.terminal_evidence.is_empty()); + } + } +} + +#[test] +fn contradictory_lifecycle_evidence_is_not_promoted_to_a_confirmed_failure() { + let analysis = analyze_fixture("contradictory"); + assert_eq!(analysis.last_confirmed_successful_phase, None); + assert_eq!(analysis.hops.len(), 1); + assert_eq!( + analysis.hops[0].state, + SccmClientHealthHopState::Contradictory + ); + assert_eq!(analysis.findings[0].class, SccmFindingClass::Symptom); + assert!(analysis.findings[0].terminal_evidence.is_empty()); +} + +#[test] +fn isolated_network_error_does_not_become_a_terminal_failure() { + let analysis = analyze_fixture("no-site-or-mp"); + let finding = &analysis.findings[0]; + assert_eq!(finding.health_phase, SccmClientHealthPhase::Assignment); + assert_eq!(finding.class, SccmFindingClass::Symptom); + assert!(finding.terminal_evidence.is_empty()); + assert_eq!( + analysis.last_confirmed_successful_phase, + Some(SccmClientHealthPhase::Authentication) + ); +} + +#[test] +fn production_output_omits_raw_messages_hosts_paths_and_correlation_values() { + for scenario in SCENARIOS + .iter() + .copied() + .filter(|name| *name != "malformed") + { + let serialized = serde_json::to_string(&analyze_fixture(scenario)).expect("analysis JSON"); + for prohibited in [ + "message", + "component", + "executionContext", + "mp-lab.contoso.invalid", + "11111111-1111-1111-1111-111111111111", + "SYNTHETIC://", + ] { + assert!( + !serialized.contains(prohibited), + "{scenario}: leaked {prohibited}: {serialized}" + ); + } + } +} + +#[test] +fn committed_multifile_corpus_matches_exact_full_output_or_error_oracles() { + let actual_directories = fs::read_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_ROOT)) + .expect("health fixture root") + .filter_map(|entry| { + let entry = entry.ok()?; + entry + .file_type() + .ok()? + .is_dir() + .then(|| entry.file_name().to_string_lossy().into_owned()) + }) + .collect::>(); + assert_eq!( + actual_directories, + SCENARIOS.iter().map(|name| (*name).to_owned()).collect() + ); + + let mut outputs = BTreeSet::new(); + for scenario in SCENARIOS { + let expected_path = fixture_directory(scenario).join("expected.json"); + let (production_output, admission_error) = match admit_fixture(scenario) { + Ok(fixture) => { + let analysis = analyze_client_health(&fixture.admitted) + .unwrap_or_else(|error| panic!("{scenario}: analyzer failed: {error}")); + let repeated = analyze_client_health(&fixture.admitted) + .unwrap_or_else(|error| panic!("{scenario}: repeat analyzer failed: {error}")); + assert_eq!( + serde_json::to_value(&analysis).unwrap(), + serde_json::to_value(&repeated).unwrap(), + "{scenario}: deterministic full output" + ); + ( + Some(normalized_output(&analysis, &fixture.artifact_ids)), + None, + ) + } + Err(error) => (None, Some(error)), + }; + + let expected = load_json(&expected_path); + let fields = expected + .as_object() + .expect("expected contract is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + fields, + BTreeSet::from(["productionAdmissionError", "productionOutput", "scenario"]), + "{scenario}: exact oracle schema" + ); + assert_eq!(expected["scenario"], *scenario); + assert_eq!( + expected["productionOutput"], + production_output.clone().unwrap_or(Value::Null), + "{scenario}: exact normalized production output" + ); + assert_eq!( + expected["productionAdmissionError"], + admission_error + .clone() + .map(Value::String) + .unwrap_or(Value::Null), + "{scenario}: exact admission error" + ); + if let Some(output) = production_output { + let bytes = serde_json::to_vec(&output).expect("normalized health JSON"); + assert!( + outputs.insert(digest(&bytes)), + "{scenario}: unique full output" + ); + } + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs new file mode 100644 index 000000000..0e817deb8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -0,0 +1,3131 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::{ + assess_client_intake, classify_artifact_name, declared_client_source_groups, SccmArtifact, + SccmClientIntakeArtifact, SccmClientIntakeAssessment, SccmClientIntakeBundle, + SccmClientIntakeCaptureGap, SccmClientIntakeCoverageGap, SccmClientIntakeError, + SccmClientIntakeFragment, SccmClientUnsupportedArtifact, SccmCoverageState, SccmRole, + SccmRotation, SccmUnknownRotation, MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const FIXTURE_ROOT: &str = "tests/fixtures/sccm/client/intake"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureManifest { + artifacts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureArtifact { + artifact_id: String, + role: String, + capture_state: String, + encoding: Option, + original_basename: String, + path_fingerprint: Option, + rotation: FixtureRotation, + source_version: Option, + captured_utc: Option, + relative_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureRotation { + kind: String, + number: Option, + timestamp: Option, + lineage_id: Option, + fragment_complete: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedIntakeContract { + contract_state: ExpectedContractState, + scenario: String, + pure_assessment: ExpectedPureAssessment, + native_design_pending: ExpectedNativeDesign, + downstream_design_pending: ExpectedDownstreamDesign, +} + +#[derive(Debug, PartialEq, Deserialize)] +enum ExpectedContractState { + #[serde(rename = "pureIntakeImplementedNativePending")] + PureIntakeImplementedNativePending, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedPureAssessment { + schema_version: u32, + groups: Vec, + fragments: Vec, + physical_artifact_ids: Vec, + unsupported_artifacts: Vec, + coverage_gaps: Vec, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedPureGroup { + logical_artifact_id: String, + coverage: SccmCoverageState, + fragment_artifact_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedPureFragment { + artifact_id: String, + basename: String, + rotation: SccmRotation, + coverage: SccmCoverageState, + path_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + rotation_lineage: Option, + relative_path: Option, + fragment_complete: Option, + configmgr_version: Option, + collected_at_utc: Option, + encoding: Option, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedUnsupportedArtifact { + artifact_id: String, + basename: String, + declared_coverage: SccmCoverageState, + classification: SccmCoverageState, + rotation: SccmRotation, + path_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + rotation_lineage: Option, + relative_path: Option, + fragment_complete: Option, + configmgr_version: Option, + collected_at_utc: Option, + encoding: Option, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedCoverageGap { + logical_artifact_id: String, + artifact_id: Option, + role: SccmRole, + coverage: SccmCoverageState, + reason: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedNativeDesign { + artifact_provenance: Vec, + #[serde(default)] + capture_assertions: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedNativeArtifactProvenance { + artifact_id: String, + byte_limit: u64, + limit_applied: bool, + bytes_copied: u64, + #[serde(default)] + sha256: Option, +} + +#[derive(Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedCaptureAssertions { + truncated: bool, + raw_byte_counted_before_decoding: bool, + exact_source_prefix: bool, + collector_injected_marker: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedDownstreamDesign { + workflow_diagnosis_expected: bool, + requests: Vec, + prohibited_claims: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ExpectedRequestDesign { + SourceGroup(ExpectedSourceGroupRequest), + IntakeCoverage(ExpectedIntakeCoverageRequest), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedSourceGroupRequest { + logical_artifact_id: String, + reason: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedIntakeCoverageRequest { + kind: ExpectedRequestKind, + reason: String, +} + +#[derive(Debug, PartialEq, Deserialize)] +enum ExpectedRequestKind { + #[serde(rename = "intakeCoverage")] + IntakeCoverage, +} + +fn fixture_directory(scenario: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join(FIXTURE_ROOT) + .join(scenario) +} + +fn load_bundle(scenario: &str) -> SccmClientIntakeBundle { + let path = fixture_directory(scenario).join("manifest.json"); + let manifest: FixtureManifest = + serde_json::from_str(&fs::read_to_string(path).expect("fixture manifest is readable")) + .expect("fixture manifest is valid"); + + SccmClientIntakeBundle { + artifacts: manifest + .artifacts + .into_iter() + .map(|fixture| { + assert_eq!(fixture.role, "client"); + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: fixture.artifact_id, + display_name: fixture.original_basename, + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: fixture.source_version, + collected_at_utc: fixture.captured_utc, + rotation: rotation(&fixture.rotation), + coverage: coverage(&fixture.capture_state), + encoding: fixture.encoding, + }, + path_fingerprint: fixture.path_fingerprint, + rotation_lineage: fixture.rotation.lineage_id, + relative_path: fixture.relative_path, + fragment_complete: fixture.rotation.fragment_complete, + declared_byte_length: None, + content_sha256: None, + } + }) + .collect(), + capture_gaps: Vec::new(), + } +} + +fn load_fixture_json(scenario: &str, file_name: &str) -> Value { + let path = fixture_directory(scenario).join(file_name); + serde_json::from_str(&fs::read_to_string(path).expect("fixture JSON is readable")) + .expect("fixture JSON is valid") +} + +fn rotation(fixture: &FixtureRotation) -> SccmRotation { + match fixture.kind.as_str() { + "current" => SccmRotation::Current, + "lo" | "loUnderscore" => SccmRotation::LoUnderscore, + "numbered" => SccmRotation::Numbered(fixture.number.expect("numbered rotation")), + "timestamped" => { + SccmRotation::Timestamped(fixture.timestamp.clone().expect("timestamped rotation")) + } + other => panic!("unsupported fixture rotation {other}"), + } +} + +fn coverage(value: &str) -> SccmCoverageState { + match value { + "captured" => SccmCoverageState::Captured, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "capped" => SccmCoverageState::Capped, + "skipped" => SccmCoverageState::Skipped, + "unsupported" => SccmCoverageState::Unsupported, + "parseFailed" => SccmCoverageState::ParseFailed, + other => panic!("unsupported fixture coverage {other}"), + } +} + +fn assessment(scenario: &str) -> cmtraceopen_parser::sccm::SccmClientIntakeAssessment { + assess_client_intake(&load_bundle(scenario)).expect("fixture intake is valid") +} + +fn lowercase_hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +impl From<&SccmClientIntakeFragment> for ExpectedPureFragment { + fn from(fragment: &SccmClientIntakeFragment) -> Self { + Self { + artifact_id: fragment.artifact_id.clone(), + basename: fragment.basename.clone(), + rotation: fragment.rotation.clone(), + coverage: fragment.coverage.clone(), + path_fingerprint: fragment.path_fingerprint.clone(), + rotation_lineage: fragment.rotation_lineage.clone(), + relative_path: fragment.relative_path.clone(), + fragment_complete: fragment.fragment_complete, + configmgr_version: fragment.configmgr_version.clone(), + collected_at_utc: fragment.collected_at_utc.clone(), + encoding: fragment.encoding.clone(), + } + } +} + +impl From<&SccmClientUnsupportedArtifact> for ExpectedUnsupportedArtifact { + fn from(artifact: &SccmClientUnsupportedArtifact) -> Self { + Self { + artifact_id: artifact.artifact_id.clone(), + basename: artifact.basename.clone(), + declared_coverage: artifact.declared_coverage.clone(), + classification: artifact.classification.clone(), + rotation: artifact.rotation.clone(), + path_fingerprint: artifact.path_fingerprint.clone(), + rotation_lineage: artifact.rotation_lineage.clone(), + relative_path: artifact.relative_path.clone(), + fragment_complete: artifact.fragment_complete, + configmgr_version: artifact.configmgr_version.clone(), + collected_at_utc: artifact.collected_at_utc.clone(), + encoding: artifact.encoding.clone(), + } + } +} + +impl From<&SccmClientIntakeCoverageGap> for ExpectedCoverageGap { + fn from(gap: &SccmClientIntakeCoverageGap) -> Self { + Self { + logical_artifact_id: gap.logical_artifact_id.clone(), + artifact_id: gap.artifact_id.clone(), + role: gap.role.clone(), + coverage: gap.coverage.clone(), + reason: gap.reason.clone(), + } + } +} + +fn normalize_pure_assessment(assessment: &SccmClientIntakeAssessment) -> ExpectedPureAssessment { + let mut fragments = BTreeMap::new(); + let groups = assessment + .groups + .iter() + .map(|group| { + let fragment_artifact_ids = group + .fragments + .iter() + .map(|fragment| { + let normalized = ExpectedPureFragment::from(fragment); + if let Some(existing) = + fragments.insert(fragment.artifact_id.clone(), normalized.clone()) + { + assert_eq!( + existing, normalized, + "one artifact ID must retain identical provenance across group memberships" + ); + } + fragment.artifact_id.clone() + }) + .collect(); + + ExpectedPureGroup { + logical_artifact_id: group.logical_artifact_id.clone(), + coverage: group.coverage.clone(), + fragment_artifact_ids, + } + }) + .collect(); + + let mut physical_ids = BTreeSet::new(); + let physical_artifact_ids = assessment + .physical_artifacts + .iter() + .map(|fragment| { + assert!( + physical_ids.insert(fragment.artifact_id.as_str()), + "physical artifact IDs must be unique" + ); + let expected_fragment = ExpectedPureFragment::from(fragment); + assert_eq!( + fragments.get(&fragment.artifact_id), + Some(&expected_fragment), + "physical artifact provenance must equal its canonical group fragment" + ); + fragment.artifact_id.clone() + }) + .collect(); + + ExpectedPureAssessment { + schema_version: assessment.schema_version, + groups, + fragments: fragments.into_values().collect(), + physical_artifact_ids, + unsupported_artifacts: assessment + .unsupported_artifacts + .iter() + .map(Into::into) + .collect(), + coverage_gaps: assessment.coverage_gaps.iter().map(Into::into).collect(), + } +} + +fn synthetic_artifact(artifact_id: &str, display_name: &str) -> SccmClientIntakeArtifact { + let source_group = match display_name { + "AppEnforce.log" => "client-app-enforce", + "CIAgent.log" => "client-policy-state", + "PolicyAgent.log" => "client-policy-agent", + _ => "unknown", + }; + let relative_path = if source_group == "unknown" { + format!("evidence/{source_group}/{display_name}") + } else { + format!("evidence/{source_group}/current/{display_name}") + }; + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: format!("fixture-{artifact_id}"), + display_name: display_name.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.TEST.0000".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some(format!("synthetic-{artifact_id}")), + rotation_lineage: None, + relative_path: Some(relative_path), + fragment_complete: Some(true), + declared_byte_length: None, + content_sha256: None, + } +} + +fn synthetic_marker( + artifact_id: &str, + display_name: &str, + coverage: SccmCoverageState, +) -> SccmClientIntakeArtifact { + let mut artifact = synthetic_artifact(artifact_id, display_name); + artifact.artifact.coverage = coverage; + artifact.path_fingerprint = None; + artifact.relative_path = None; + artifact.fragment_complete = Some(false); + artifact +} + +fn opaque_numbered_artifact(number: usize) -> SccmClientIntakeArtifact { + let rotation_number = u32::try_from(number).expect("test artifact number fits u32"); + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: format!("sccm-artifact:v1:sha256:{number:064x}"), + display_name: format!("PolicyAgent.log.{number}"), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.TEST.0000".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), + rotation: SccmRotation::Numbered(rotation_number), + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some(format!("sha256:{number:064x}")), + rotation_lineage: None, + relative_path: Some(format!( + "evidence/client-policy-agent/numbered-{number}/PolicyAgent.log.{number}" + )), + fragment_complete: Some(true), + declared_byte_length: None, + content_sha256: None, + } +} + +#[test] +fn intake_rejects_more_than_the_v1_artifact_limit_before_validation_indexes() { + // Keep the input otherwise invalid (all declarations collide) so this + // asserts that the public limit runs before the per-artifact indexes. + let duplicate = synthetic_artifact("limit", "PolicyAgent.log"); + let artifacts = vec![duplicate; MAX_SCCM_CLIENT_INTAKE_ARTIFACTS + 1]; + + let error = assess_client_intake(&SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }) + .expect_err("the v1 client intake artifact ceiling must fail closed"); + + assert_eq!( + error.to_string(), + "client intake artifact count exceeds the supported limit" + ); +} + +#[test] +fn intake_wire_rejects_more_than_the_v1_artifact_limit_while_deserializing() { + let boundary_artifacts = (1..=MAX_SCCM_CLIENT_INTAKE_ARTIFACTS) + .map(opaque_numbered_artifact) + .collect::>(); + let boundary = serde_json::json!({ + "artifacts": boundary_artifacts, + }); + let decoded: SccmClientIntakeBundle = + serde_json::from_value(boundary).expect("the declared v1 boundary is accepted"); + assert_eq!(decoded.artifacts.len(), MAX_SCCM_CLIENT_INTAKE_ARTIFACTS); + + let artifact = serde_json::to_value(synthetic_artifact("wire-limit", "PolicyAgent.log")) + .expect("synthetic intake artifact serializes"); + let oversized = serde_json::json!({ + "artifacts": vec![artifact; MAX_SCCM_CLIENT_INTAKE_ARTIFACTS + 1], + }); + let error = serde_json::from_value::(oversized) + .expect_err("the wire must reject an oversized artifact sequence"); + assert!( + error.to_string().contains("artifact count exceeds"), + "the wire must report the bounded-contract violation: {error}" + ); +} + +#[test] +fn intake_wire_rejects_more_than_the_v1_artifact_limit_from_json_text() { + let artifact = synthetic_artifact("wire-limit-text", "PolicyAgent.log"); + let oversized = serde_json::json!({ + "artifacts": vec![artifact; MAX_SCCM_CLIENT_INTAKE_ARTIFACTS + 1], + }); + let text = oversized.to_string(); + + let error = serde_json::from_str::(&text) + .expect_err("streaming JSON must reject an oversized artifact sequence"); + assert!( + error.to_string().contains("artifact count exceeds"), + "the streaming fallback must report the bounded-contract violation: {error}" + ); +} + +#[test] +fn capped_omitted_rotation_degrades_group_coverage_without_relabeling_current_capture() { + let current = synthetic_artifact("current", "PolicyAgent.log"); + let omitted_rotation = SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }; + + let assessment = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![current], + capture_gaps: vec![omitted_rotation], + }) + .expect("a coverage-only omitted rotation is a valid client intake declaration"); + + let group = assessment + .group("client-policy-agent") + .expect("policy agent group"); + assert_eq!(group.coverage, SccmCoverageState::Capped); + assert_eq!(group.fragments.len(), 1); + assert_eq!(group.fragments[0].coverage, SccmCoverageState::Captured); + assert_eq!(assessment.physical_artifacts.len(), 1); + assert_eq!( + assessment.physical_artifacts[0].coverage, + SccmCoverageState::Captured + ); + assert_eq!(assessment.capture_gaps.len(), 1); + assert_eq!( + assessment.capture_gaps[0].artifact_id, + "fixture-capped-rotation" + ); + assert!(assessment.coverage_gaps.iter().any(|gap| { + gap.logical_artifact_id == "client-policy-agent" + && gap.artifact_id.as_deref() == Some("fixture-capped-rotation") + && gap.coverage == SccmCoverageState::Capped + })); + + let serialized = serde_json::to_value(&assessment).expect("assessment serializes"); + let gap = &serialized["captureGaps"][0]; + assert!(gap.get("relativePath").is_none()); + assert!(gap.get("bytesCopied").is_none()); + assert!(gap.get("bytesRetained").is_none()); +} + +#[test] +fn intake_wire_rejects_a_combined_artifact_and_capture_gap_count_above_the_v1_limit() { + let artifact = serde_json::to_value(synthetic_artifact("wire-current", "PolicyAgent.log")) + .expect("synthetic intake artifact serializes"); + let capture_gap = serde_json::to_value(SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }) + .expect("synthetic capture gap serializes"); + let oversized_values = [ + serde_json::json!({ + "artifacts": [artifact.clone()], + "captureGaps": vec![capture_gap.clone(); MAX_SCCM_CLIENT_INTAKE_ARTIFACTS], + }), + serde_json::json!({ + "artifacts": vec![artifact; MAX_SCCM_CLIENT_INTAKE_ARTIFACTS], + "captureGaps": [capture_gap], + }), + ]; + + for oversized in oversized_values { + let error = serde_json::from_value::(oversized) + .expect_err("the shared v1 declaration ceiling must reject a combined oversized wire"); + assert!( + error.to_string().contains("artifact count exceeds"), + "the wire must report the shared declaration bound: {error}" + ); + } +} + +#[test] +fn intake_accepts_the_shared_v1_boundary_across_physical_and_coverage_only_declarations() { + let artifacts = (1..MAX_SCCM_CLIENT_INTAKE_ARTIFACTS) + .map(opaque_numbered_artifact) + .collect(); + let capture_gap = SccmClientIntakeCaptureGap { + artifact_id: format!("sccm-artifact:v1:sha256:{MAX_SCCM_CLIENT_INTAKE_ARTIFACTS:064x}"), + basename: format!("PolicyAgent.log.{MAX_SCCM_CLIENT_INTAKE_ARTIFACTS}"), + rotation: SccmRotation::Numbered(MAX_SCCM_CLIENT_INTAKE_ARTIFACTS as u32), + coverage: SccmCoverageState::Capped, + path_fingerprint: format!("sha256:{MAX_SCCM_CLIENT_INTAKE_ARTIFACTS:064x}"), + rotation_lineage: format!( + "cmtraceopen.lineage.sha256.v1:{MAX_SCCM_CLIENT_INTAKE_ARTIFACTS:064x}" + ), + }; + + let assessment = assess_client_intake(&SccmClientIntakeBundle { + artifacts, + capture_gaps: vec![capture_gap], + }) + .expect("the shared v1 declaration boundary is accepted exactly"); + assert_eq!( + assessment.physical_artifacts.len(), + MAX_SCCM_CLIENT_INTAKE_ARTIFACTS - 1 + ); + assert_eq!(assessment.capture_gaps.len(), 1); + + let wire = serde_json::to_value(&assessment) + .expect("the canonical shared boundary assessment serializes"); + let decoded = serde_json::from_value::(wire) + .expect("the canonical shared boundary assessment deserializes"); + assert_eq!(decoded, assessment); +} + +#[test] +fn legacy_bundle_wire_omits_the_additive_empty_capture_gap_field() { + let artifact = serde_json::to_value(synthetic_artifact("policy-current", "PolicyAgent.log")) + .expect("synthetic intake artifact serializes"); + let legacy_wire = serde_json::json!({ "artifacts": [artifact] }); + + let decoded: SccmClientIntakeBundle = + serde_json::from_value(legacy_wire).expect("pre-gap bundle wire remains accepted"); + assert!(decoded.capture_gaps.is_empty()); + let reserialized = serde_json::to_value(decoded).expect("bundle reserializes"); + assert!( + reserialized.get("captureGaps").is_none(), + "an empty additive field must preserve the existing wire shape" + ); +} + +#[test] +fn intake_rejects_malformed_coverage_only_capture_gap() { + let malformed = SccmClientIntakeCaptureGap { + artifact_id: "fixture-captured-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Captured, + path_fingerprint: "synthetic-captured-rotation".to_owned(), + rotation_lineage: "synthetic:captured-rotation".to_owned(), + }; + + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: Vec::new(), + capture_gaps: vec![malformed], + }), + Err(SccmClientIntakeError::InvalidCaptureGap), + "coverage-only gaps must not be able to claim captured physical evidence" + ); +} + +#[test] +fn capture_gap_wire_rejects_physical_path_or_byte_claims() { + let capture_gap = serde_json::to_value(SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }) + .expect("synthetic capture gap serializes"); + let mut with_path = capture_gap.clone(); + with_path["relativePath"] = + serde_json::json!("evidence/client-policy-agent/numbered-1/PolicyAgent.log.1"); + let mut with_bytes = capture_gap; + with_bytes["bytesCopied"] = serde_json::json!(1); + + for malformed_gap in [with_path, with_bytes] { + assert!( + serde_json::from_value::(serde_json::json!({ + "artifacts": [], + "captureGaps": [malformed_gap], + })) + .is_err(), + "a coverage-only gap must reject physical evidence fields" + ); + } +} + +fn capture_gap_wire_value(gap: &SccmClientIntakeCaptureGap) -> Value { + serde_json::json!({ + "artifactId": gap.artifact_id, + "basename": gap.basename, + "rotation": gap.rotation, + "coverage": gap.coverage, + "pathFingerprint": gap.path_fingerprint, + "rotationLineage": gap.rotation_lineage, + }) +} + +#[test] +fn standalone_capture_gap_serde_and_direct_assessment_reject_unsafe_public_state() { + let valid = SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }; + let mut captured = valid.clone(); + captured.coverage = SccmCoverageState::Captured; + let mut unsafe_id = valid.clone(); + unsafe_id.artifact_id = r"C:\Users\RealUser\PolicyAgent.log.1".to_owned(); + let mut raw_fingerprint = valid.clone(); + raw_fingerprint.path_fingerprint = r"C:\Users\RealUser".to_owned(); + let mut unversioned_lineage = valid.clone(); + unversioned_lineage.rotation_lineage = "lineage-1".to_owned(); + + for invalid in [captured, unsafe_id, raw_fingerprint, unversioned_lineage] { + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: Vec::new(), + capture_gaps: vec![invalid.clone()], + }), + Err(SccmClientIntakeError::InvalidCaptureGap), + "direct assessment must reject the unsafe capture-gap shape" + ); + assert!( + serde_json::to_value(&invalid).is_err(), + "standalone serialization must validate post-construction mutation" + ); + + let wire = capture_gap_wire_value(&invalid); + assert!( + serde_json::from_value::(wire.clone()).is_err(), + "from_value must validate standalone capture-gap input" + ); + assert!( + serde_json::from_str::(&wire.to_string()).is_err(), + "from_str must validate standalone capture-gap input" + ); + } +} + +#[test] +fn standalone_capture_gap_serde_preserves_parse_failed_as_coverage_only() { + let gap = SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::ParseFailed, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }; + + let wire = serde_json::to_value(&gap).expect("ParseFailed remains a valid unretained gap"); + assert_eq!( + serde_json::from_value::(wire.clone()) + .expect("from_value accepts the reviewed ParseFailed state"), + gap + ); + assert_eq!( + serde_json::from_str::(&wire.to_string()) + .expect("from_str accepts the reviewed ParseFailed state"), + gap + ); +} + +#[test] +fn bundle_serialization_rejects_colliding_standalone_valid_capture_gaps() { + let first = SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation-numbered-1".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }; + let mut second = first.clone(); + second.artifact_id = "fixture-capped-rotation-numbered-2".to_owned(); + assert!(serde_json::to_value(&first).is_ok()); + assert!(serde_json::to_value(&second).is_ok()); + let bundle = SccmClientIntakeBundle { + artifacts: Vec::new(), + capture_gaps: vec![first, second], + }; + + assert_eq!( + assess_client_intake(&bundle), + Err(SccmClientIntakeError::CollidingPhysicalIdentity) + ); + assert!( + serde_json::to_value(&bundle).is_err(), + "bundle serialization must validate cross-declaration collisions" + ); +} + +fn assert_bundle_wire_rejected_at_both_json_boundaries(label: &str, wire: Value) { + let value_rejected = serde_json::from_value::(wire.clone()).is_err(); + let text_rejected = serde_json::from_str::(&wire.to_string()).is_err(); + assert!( + value_rejected && text_rejected, + "invalid bundle declaration was accepted: {label}; \ + from_value rejected={value_rejected}, from_str rejected={text_rejected}" + ); +} + +#[test] +fn bundle_deserialization_revalidates_unsafe_collision_and_capture_gap_inputs() { + let artifact = serde_json::to_value(synthetic_artifact("policy-current", "PolicyAgent.log")) + .expect("valid artifact serializes"); + + let mut unsafe_identity = serde_json::json!({ "artifacts": [artifact.clone()] }); + unsafe_identity["artifacts"][0]["artifact"]["artifactId"] = + serde_json::json!(r"C:\Users\RealUser\PolicyAgent.log"); + + let duplicate_artifact = serde_json::json!({ + "artifacts": [artifact.clone(), artifact.clone()], + }); + + let first_gap = SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation-numbered-1".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }; + let mut second_gap = first_gap.clone(); + second_gap.artifact_id = "fixture-capped-rotation-numbered-2".to_owned(); + let colliding_gaps = serde_json::json!({ + "artifacts": [], + "captureGaps": [ + capture_gap_wire_value(&first_gap), + capture_gap_wire_value(&second_gap), + ], + }); + + let mut invalid_gap = first_gap.clone(); + invalid_gap.coverage = SccmCoverageState::Captured; + let invalid_gap_shape = serde_json::json!({ + "artifacts": [], + "captureGaps": [capture_gap_wire_value(&invalid_gap)], + }); + + let mut duplicate_across_declaration_kinds = first_gap; + duplicate_across_declaration_kinds.artifact_id = "fixture-policy-current".to_owned(); + let duplicate_artifact_and_gap = serde_json::json!({ + "artifacts": [artifact], + "captureGaps": [capture_gap_wire_value(&duplicate_across_declaration_kinds)], + }); + + for (label, wire) in [ + ("unsafe artifact identity", unsafe_identity), + ("duplicate artifact identity", duplicate_artifact), + ("colliding capture gaps", colliding_gaps), + ("invalid capture-gap shape", invalid_gap_shape), + ( + "duplicate artifact and capture-gap identity", + duplicate_artifact_and_gap, + ), + ] { + assert_bundle_wire_rejected_at_both_json_boundaries(label, wire); + } +} + +#[test] +fn shared_decode_quota_wins_before_malformed_second_field_in_both_wire_orders() { + let artifact = + serde_json::to_value(synthetic_artifact("boundary-candidate", "PolicyAgent.log")) + .expect("synthetic intake artifact serializes"); + let capture_gap = capture_gap_wire_value(&SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }); + let artifacts = serde_json::to_string(&vec![artifact; MAX_SCCM_CLIENT_INTAKE_ARTIFACTS]) + .expect("artifact boundary wire serializes"); + let capture_gaps = serde_json::to_string(&vec![capture_gap; MAX_SCCM_CLIENT_INTAKE_ARTIFACTS]) + .expect("capture-gap boundary wire serializes"); + let artifacts_first = + format!(r#"{{"artifacts":{artifacts},"captureGaps":[{{"coverage":"captured"}}]}}"#); + let capture_gaps_first = + format!(r#"{{"captureGaps":{capture_gaps},"artifacts":[{{"artifact":null}}]}}"#); + + for wire in [artifacts_first, capture_gaps_first] { + let error = serde_json::from_str::(&wire) + .expect_err("the first field exhausts the shared declaration quota"); + assert!( + error + .to_string() + .starts_with(&SccmClientIntakeError::ArtifactLimitExceeded.to_string()), + "an element beyond the shared quota must not be semantically decoded: {error}" + ); + } +} + +#[test] +fn intake_rejects_capture_gap_that_conflicts_with_physical_lineage() { + let mut current = synthetic_artifact("current", "PolicyAgent.log"); + current.path_fingerprint = Some("synthetic-current-rotation".to_owned()); + current.rotation_lineage = Some("synthetic:current-rotation".to_owned()); + let colliding_gap = SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-current-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }; + + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![current], + capture_gaps: vec![colliding_gap], + }), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "a coverage-only rotation must remain bound to the same collision and lineage rules" + ); +} + +#[test] +fn capture_gap_projection_is_deterministic_and_round_trips() { + let current = synthetic_artifact("current", "PolicyAgent.log"); + let first = SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation-numbered-1".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }; + let second = SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation-numbered-2".to_owned(), + basename: "PolicyAgent.log.2".to_owned(), + rotation: SccmRotation::Numbered(2), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }; + + let ordered = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![current.clone()], + capture_gaps: vec![first.clone(), second.clone()], + }) + .expect("ordered capture gaps are valid"); + let reversed = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![current], + capture_gaps: vec![second, first], + }) + .expect("reversed capture gaps are equally valid"); + + assert_eq!(ordered, reversed); + assert_eq!( + ordered + .capture_gaps + .iter() + .map(|gap| gap.artifact_id.as_str()) + .collect::>(), + vec![ + "fixture-capped-rotation-numbered-1", + "fixture-capped-rotation-numbered-2", + ] + ); + let serialized = serde_json::to_value(&ordered).expect("assessment serializes"); + let decoded: SccmClientIntakeAssessment = + serde_json::from_value(serialized).expect("canonical capture gaps round trip"); + assert_eq!(decoded, ordered); +} + +/// Every assertion that a serialized projection did not leak an identity must +/// casefold its input and route through this helper. A bare substring check +/// against the original-case JSON has twice missed a form this covers: the +/// JSON-escaped backslash root, and the forward-slash normalized root that +/// carries no `RealUser` sentinel. Positive assertions may keep their exact +/// case, since they pin what the projection must reproduce verbatim. +fn serialized_json_contains_windows_user_root(serialized_casefolded: &str) -> bool { + debug_assert_eq!( + serialized_casefolded, + serialized_casefolded.to_ascii_lowercase(), + "caller must casefold before probing for a Windows user root" + ); + serialized_casefolded.contains(r"c:\\users") || serialized_casefolded.contains("c:/users") +} + +#[test] +fn serialized_privacy_probe_detects_json_escaped_windows_user_path() { + let leaked_json = serde_json::to_string(&serde_json::json!({ + "originalPath": r"C:\Users\RealUser\PolicyAgent.log" + })) + .expect("leak probe serializes") + .to_ascii_lowercase(); + + assert!( + serialized_json_contains_windows_user_root(&leaked_json), + "privacy probe must detect a Windows user path after JSON escaping: {leaked_json}" + ); +} + +#[test] +fn serialized_privacy_probe_detects_forward_slash_normalized_windows_user_path() { + let leaked_json = serde_json::to_string(&serde_json::json!({ + "relativePath": "C:/Users/RealUser/PolicyAgent.log" + })) + .expect("leak probe serializes") + .to_ascii_lowercase(); + + assert!( + serialized_json_contains_windows_user_root(&leaked_json), + "privacy probe must detect a forward-slash normalized Windows user path \ + even without the RealUser sentinel: {leaked_json}" + ); +} + +#[test] +fn public_assessment_deserialization_rejects_forged_coverage_and_identity() { + let mut forged_coverage = + serde_json::to_value(assessment("missing-root")).expect("assessment serializes"); + forged_coverage["groups"][0]["coverage"] = serde_json::json!("captured"); + assert!( + serde_json::from_value::(forged_coverage).is_err(), + "a standalone assessment must not deserialize coverage that contradicts its fragments" + ); + + let mut leaked_identity = + serde_json::to_value(assessment("complete")).expect("assessment serializes"); + let original_artifact_id = leaked_identity["physicalArtifacts"][0]["artifactId"] + .as_str() + .expect("physical artifact ID") + .to_owned(); + leaked_identity["physicalArtifacts"][0]["artifactId"] = + serde_json::json!(r"C:\Users\RealUser\PolicyAgent.log"); + leaked_identity["physicalArtifacts"][0]["pathFingerprint"] = + serde_json::json!("synthetic:realuser"); + leaked_identity["physicalArtifacts"][0]["relativePath"] = + serde_json::json!(r"C:\Users\RealUser\PolicyAgent.log"); + for group in leaked_identity["groups"] + .as_array_mut() + .expect("assessment groups") + { + for fragment in group["fragments"].as_array_mut().expect("group fragments") { + if fragment["artifactId"] == original_artifact_id { + fragment["artifactId"] = serde_json::json!(r"C:\Users\RealUser\PolicyAgent.log"); + fragment["pathFingerprint"] = serde_json::json!("synthetic:realuser"); + fragment["relativePath"] = serde_json::json!(r"C:\Users\RealUser\PolicyAgent.log"); + } + } + } + assert!( + serde_json::from_value::(leaked_identity).is_err(), + "a standalone assessment must not deserialize raw identity-bearing provenance" + ); +} + +#[test] +fn public_assessment_capture_gaps_are_bounded_before_projection_validation() { + let mut wire = + serde_json::to_value(assessment("missing-root")).expect("canonical assessment serializes"); + let gap = capture_gap_wire_value(&SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }); + wire["captureGaps"] = serde_json::json!(vec![gap; MAX_SCCM_CLIENT_INTAKE_ARTIFACTS + 1]); + + let value_error = serde_json::from_value::(wire.clone()) + .expect_err("from_value must reject capture gap 4097 before projection validation"); + let text = wire.to_string(); + let text_error = serde_json::from_str::(&text) + .expect_err("from_str must reject capture gap 4097 before projection validation"); + let limit_error = SccmClientIntakeError::ArtifactLimitExceeded.to_string(); + let value_stopped_early = value_error.to_string().starts_with(&limit_error); + let text_stopped_early = text_error.to_string().starts_with(&limit_error); + assert!( + value_stopped_early && text_stopped_early, + "oversized assessment capture gaps reached canonical projection validation; \ + from_value early={value_stopped_early} ({value_error}); \ + from_str early={text_stopped_early} ({text_error})" + ); +} + +#[test] +fn public_assessment_serialization_rejects_post_build_invalid_mutation() { + let mut forged_coverage = assessment("missing-root"); + forged_coverage.groups[0].coverage = SccmCoverageState::Captured; + assert!( + serde_json::to_string(&forged_coverage).is_err(), + "post-build coverage mutation must not cross the public wire boundary" + ); + + let mut leaked_identity = assessment("complete"); + let original_artifact_id = leaked_identity.physical_artifacts[0].artifact_id.clone(); + leaked_identity.physical_artifacts[0].artifact_id = + r"C:\Users\RealUser\PolicyAgent.log".to_owned(); + leaked_identity.physical_artifacts[0].path_fingerprint = Some("synthetic:realuser".to_owned()); + leaked_identity.physical_artifacts[0].relative_path = + Some(r"C:\Users\RealUser\PolicyAgent.log".to_owned()); + for group in &mut leaked_identity.groups { + for fragment in &mut group.fragments { + if fragment.artifact_id == original_artifact_id { + fragment.artifact_id = r"C:\Users\RealUser\PolicyAgent.log".to_owned(); + fragment.path_fingerprint = Some("synthetic:realuser".to_owned()); + fragment.relative_path = Some(r"C:\Users\RealUser\PolicyAgent.log".to_owned()); + } + } + } + assert!( + serde_json::to_string(&leaked_identity).is_err(), + "post-build identity mutation must not cross the public wire boundary" + ); +} + +#[test] +fn collection_timestamp_is_projected_as_canonical_utc() { + let mut artifact = synthetic_artifact("offset", "PolicyAgent.log"); + artifact.artifact.collected_at_utc = Some("2026-07-30T05:00:00+05:00".to_owned()); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }) + .expect("a valid RFC 3339 collection instant remains representable"); + assert_eq!( + intake + .group("client-policy-agent") + .expect("policy group") + .fragments[0] + .collected_at_utc + .as_deref(), + Some("2026-07-30T00:00:00Z"), + "collectedAtUtc must have one deterministic UTC spelling" + ); + + let serialized = serde_json::to_value(&intake).expect("assessment serializes"); + assert!( + serde_json::from_value::(serialized).is_ok(), + "canonical assessment output must retain a valid standalone wire round trip" + ); +} + +#[test] +fn group_level_coverage_gaps_serialize_an_explicit_null_artifact_id() { + let serialized = + serde_json::to_value(assessment("rotations")).expect("client intake assessment serializes"); + let gaps = serialized["coverageGaps"] + .as_array() + .expect("coverage gaps serialize as an array"); + + assert!( + !gaps.is_empty(), + "the rotations fixture has group-level gaps" + ); + assert!( + gaps.iter().all(|gap| { + gap.as_object() + .is_some_and(|object| object.get("artifactId").is_some_and(Value::is_null)) + }), + "group-level gaps must serialize artifactId explicitly as null" + ); +} + +#[test] +fn every_declared_client_basename_is_supported_by_the_authoritative_catalog() { + for group in declared_client_source_groups() { + for basename in group.accepted_basenames { + let classified = classify_artifact_name(&basename, SccmRole::Client); + assert!( + classified.supported_for_diagnosis, + "{basename} in {} bypasses the shared SCCM catalog", + group.logical_artifact_id + ); + assert_eq!( + classified.basename, basename, + "{basename} in {} must use the shared catalog's exact canonical basename", + group.logical_artifact_id + ); + assert_eq!( + classified.uses_ccm_records, + !matches!( + basename.as_str(), + "client.msi.log" | "ReportingEvents.log" | "CBS.log" + ), + "the shared catalog must not route a non-CCM supplement through raw CCM" + ); + } + } +} + +#[test] +fn expected_pure_assessments_are_complete_exact_and_deterministic() { + for scenario in [ + "complete", + "rotations", + "missing-root", + "access-denied", + "capped", + "collision", + ] { + let expected: ExpectedIntakeContract = serde_json::from_str( + &fs::read_to_string(fixture_directory(scenario).join("expected.json")) + .expect("expected fixture is readable"), + ) + .expect("expected fixture has the exact typed pure contract"); + + assert_eq!( + expected.contract_state, + ExpectedContractState::PureIntakeImplementedNativePending + ); + assert_eq!(expected.scenario, scenario); + assert_eq!( + expected.pure_assessment, + normalize_pure_assessment(&assessment(scenario)), + "{scenario}: expected pure assessment must cover the entire deterministic output" + ); + } +} + +fn expected_contract_matches(value: Value, scenario: &str) -> bool { + serde_json::from_value::(value).is_ok_and(|expected| { + expected.contract_state == ExpectedContractState::PureIntakeImplementedNativePending + && expected.scenario == scenario + && expected.pure_assessment == normalize_pure_assessment(&assessment(scenario)) + }) +} + +#[test] +fn exact_pure_oracle_rejects_unknown_omitted_reordered_and_forged_output() { + let complete = load_fixture_json("complete", "expected.json"); + + let mut unknown_root = complete.clone(); + unknown_root["unexpected"] = serde_json::json!(true); + assert!(!expected_contract_matches(unknown_root, "complete")); + + let mut unknown_fragment = complete.clone(); + unknown_fragment["pureAssessment"]["fragments"][0]["unexpected"] = serde_json::json!(true); + assert!(!expected_contract_matches(unknown_fragment, "complete")); + + let mut omitted_group = complete.clone(); + omitted_group["pureAssessment"]["groups"] + .as_array_mut() + .expect("expected groups") + .pop(); + assert!(!expected_contract_matches(omitted_group, "complete")); + + let mut reordered_groups = complete.clone(); + reordered_groups["pureAssessment"]["groups"] + .as_array_mut() + .expect("expected groups") + .swap(0, 1); + assert!(!expected_contract_matches(reordered_groups, "complete")); + + let mut forged_fragment = complete.clone(); + forged_fragment["pureAssessment"]["fragments"][0]["relativePath"] = + serde_json::json!("evidence/client-app-enforce/current/forged.log"); + assert!(!expected_contract_matches(forged_fragment, "complete")); + + let mut reordered_physical = complete; + reordered_physical["pureAssessment"]["physicalArtifactIds"] + .as_array_mut() + .expect("physical artifact IDs") + .swap(0, 1); + assert!(!expected_contract_matches(reordered_physical, "complete")); + + let mut omitted_gap = load_fixture_json("rotations", "expected.json"); + omitted_gap["pureAssessment"]["coverageGaps"] + .as_array_mut() + .expect("coverage gaps") + .pop(); + assert!(!expected_contract_matches(omitted_gap, "rotations")); +} + +#[test] +fn pending_request_schema_rejects_missing_ambiguous_and_unknown_discriminators() { + let missing_root = load_fixture_json("missing-root", "expected.json"); + assert!(serde_json::from_value::(missing_root.clone()).is_ok()); + + let mut missing_discriminator = missing_root.clone(); + missing_discriminator["downstreamDesignPending"]["requests"][0] + .as_object_mut() + .expect("pending request") + .remove("kind"); + assert!( + serde_json::from_value::(missing_discriminator).is_err(), + "a pending request without kind or logicalArtifactId must fail closed" + ); + + let mut ambiguous = missing_root.clone(); + ambiguous["downstreamDesignPending"]["requests"][0]["logicalArtifactId"] = + serde_json::json!("client-policy-agent"); + assert!( + serde_json::from_value::(ambiguous).is_err(), + "a pending request cannot select both request variants" + ); + + let mut unknown = missing_root; + unknown["downstreamDesignPending"]["requests"][0]["unexpected"] = serde_json::json!(true); + assert!( + serde_json::from_value::(unknown).is_err(), + "pending request variants must reject unknown fields" + ); +} + +#[test] +fn pending_native_design_is_typed_bounded_and_fixture_backed() { + let declared_groups = declared_client_source_groups() + .into_iter() + .map(|group| group.logical_artifact_id) + .collect::>(); + + for scenario in [ + "complete", + "rotations", + "missing-root", + "access-denied", + "capped", + "collision", + ] { + let expected: ExpectedIntakeContract = serde_json::from_str( + &fs::read_to_string(fixture_directory(scenario).join("expected.json")) + .expect("expected fixture is readable"), + ) + .expect("expected fixture has the exact typed contract"); + let manifest = load_fixture_json(scenario, "manifest.json"); + let intake = assessment(scenario); + let provenance = &expected.native_design_pending.artifact_provenance; + + assert!( + provenance + .windows(2) + .all(|pair| pair[0].artifact_id < pair[1].artifact_id), + "{scenario}: pending native provenance must be stable-sorted and unique" + ); + assert_eq!( + provenance + .iter() + .map(|artifact| artifact.artifact_id.as_str()) + .collect::>(), + intake + .physical_artifacts + .iter() + .map(|artifact| artifact.artifact_id.as_str()) + .collect::>(), + "{scenario}: pending native provenance must cover every physical artifact exactly" + ); + + for artifact in provenance { + let manifest_artifact = manifest["artifacts"] + .as_array() + .expect("manifest artifacts") + .iter() + .find(|candidate| candidate["artifactId"] == artifact.artifact_id) + .expect("pending provenance refers to a manifest artifact"); + let manifest_byte_limit = manifest_artifact["collectionLimit"]["byteLimit"] + .as_u64() + .expect("physical manifest byteLimit is an unsigned integer"); + let manifest_limit_applied = manifest_artifact["collectionLimit"]["limitApplied"] + .as_bool() + .expect("physical manifest limitApplied is a boolean"); + let manifest_bytes_copied = manifest_artifact["bytesCopied"] + .as_u64() + .expect("physical manifest bytesCopied is an unsigned integer"); + + assert_eq!(artifact.byte_limit, manifest_byte_limit); + assert_eq!(artifact.limit_applied, manifest_limit_applied); + assert_eq!(artifact.bytes_copied, manifest_bytes_copied); + assert!(artifact.byte_limit > 0, "capture limits must be bounded"); + if artifact.limit_applied { + assert_eq!(artifact.bytes_copied, artifact.byte_limit); + assert!(artifact.bytes_copied > 0); + assert!( + artifact.sha256.is_some(), + "capped evidence must pin the exact retained prefix" + ); + } else { + assert!(artifact.bytes_copied <= artifact.byte_limit); + } + + let relative_path = manifest_artifact["relativePath"] + .as_str() + .expect("physical manifest artifact has a relative path"); + let bytes = fs::read(fixture_directory(scenario).join(relative_path)) + .expect("physical fixture evidence is readable"); + assert_eq!(bytes.len() as u64, artifact.bytes_copied); + if let Some(expected_sha256) = artifact.sha256.as_deref() { + assert_eq!( + lowercase_hex(Sha256::digest(&bytes).as_ref()), + expected_sha256 + ); + } + } + + let expected_capture_assertions = if scenario == "capped" { + Some(ExpectedCaptureAssertions { + truncated: true, + raw_byte_counted_before_decoding: true, + exact_source_prefix: true, + collector_injected_marker: false, + }) + } else { + None + }; + assert_eq!( + expected.native_design_pending.capture_assertions, expected_capture_assertions, + "{scenario}: only the capped fixture carries pending native prefix assertions" + ); + if let Some(assertions) = expected_capture_assertions { + let capped_artifact = manifest["artifacts"] + .as_array() + .expect("manifest artifacts") + .iter() + .find(|artifact| artifact["captureState"] == "capped") + .expect("capped scenario declares a capped artifact"); + assert_eq!( + capped_artifact["truncated"].as_bool(), + Some(assertions.truncated), + "capped native design must bind the manifest truncation marker" + ); + } + + let downstream = expected.downstream_design_pending; + assert!( + !downstream.workflow_diagnosis_expected, + "{scenario}: intake alone must not claim a workflow diagnosis" + ); + for request in downstream.requests { + let reason = match request { + ExpectedRequestDesign::SourceGroup(request) => { + assert!( + declared_groups.contains(&request.logical_artifact_id), + "{scenario}: request uses an undeclared client source group" + ); + request.reason + } + ExpectedRequestDesign::IntakeCoverage(request) => { + assert_eq!(request.kind, ExpectedRequestKind::IntakeCoverage); + request.reason + } + }; + assert!( + !reason.trim().is_empty(), + "{scenario}: request reason is empty" + ); + } + assert_eq!( + downstream + .prohibited_claims + .iter() + .collect::>() + .len(), + downstream.prohibited_claims.len(), + "{scenario}: prohibited claims must be unique" + ); + assert!( + downstream + .prohibited_claims + .iter() + .all(|claim| !claim.trim().is_empty()), + "{scenario}: prohibited claims must not be empty" + ); + } +} + +#[test] +fn complete_client_intake_covers_every_declared_group_without_a_diagnosis() { + let declared = declared_client_source_groups(); + let intake = assessment("complete"); + + assert_eq!(declared.len(), 18); + assert_eq!(intake.groups.len(), declared.len()); + assert!(intake + .groups + .iter() + .all(|group| group.coverage == SccmCoverageState::Captured)); + assert!(intake.coverage_gaps.is_empty()); + assert!(intake.unsupported_artifacts.is_empty()); + + let location = intake.group("client-location").expect("location group"); + let content = intake.group("client-content").expect("content group"); + let location_services_id = "fixture-complete-location-services-root-a-current"; + assert!(location + .fragments + .iter() + .any(|fragment| fragment.artifact_id == location_services_id)); + assert!(content + .fragments + .iter() + .any(|fragment| fragment.artifact_id == location_services_id)); + assert_eq!( + intake + .physical_artifacts + .iter() + .filter(|fragment| fragment.artifact_id == location_services_id) + .count(), + 1, + "LocationServices is captured once and shared by group projections" + ); +} + +#[test] +fn rotations_are_one_group_with_stable_physical_order_and_reordering_is_deterministic() { + let bundle = load_bundle("rotations"); + let intake = assess_client_intake(&bundle).expect("rotation intake"); + let group = intake + .group("client-app-enforce") + .expect("app enforcement group"); + + assert_eq!(group.coverage, SccmCoverageState::Captured); + assert_eq!(group.fragments.len(), 3); + assert_eq!(group.fragments[0].rotation, SccmRotation::Current); + assert_eq!(group.fragments[1].rotation, SccmRotation::LoUnderscore); + assert_eq!(group.fragments[2].rotation, SccmRotation::Numbered(2)); + assert_eq!( + group + .fragments + .iter() + .filter_map(|fragment| fragment.path_fingerprint.as_deref()) + .collect::>() + .len(), + 1, + "one configured source fingerprint is retained across its rotations" + ); + assert_eq!( + group + .fragments + .iter() + .filter_map(|fragment| fragment.rotation_lineage.as_deref()) + .collect::>(), + BTreeSet::from(["synthetic:app-enforce-root-a"]), + "every rotation retains the immutable source lineage" + ); + + let mut reordered = bundle; + reordered.artifacts.reverse(); + let reordered = assess_client_intake(&reordered).expect("reordered intake"); + assert_eq!( + serde_json::to_string(&reordered).expect("reordered JSON"), + serde_json::to_string(&intake).expect("intake JSON") + ); + + let mut duplicate_bundle = load_bundle("rotations"); + let mut duplicate = duplicate_bundle.artifacts[1].clone(); + duplicate.artifact.artifact_id = "fixture-rotations-app-enforce-root-a-lo-two".to_owned(); + duplicate.relative_path = + Some("evidence/client-app-enforce/root-a/lo/AppEnforce.lo_".to_owned()); + duplicate_bundle.artifacts.push(duplicate); + assert_eq!( + assess_client_intake(&duplicate_bundle), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "one lineage cannot declare the same physical rotation twice" + ); + + let mut conflicting_root_bundle = load_bundle("rotations"); + let mut conflicting_root = conflicting_root_bundle.artifacts[1].clone(); + conflicting_root.artifact.artifact_id = "fixture-rotations-app-enforce-root-b-lo".to_owned(); + conflicting_root.path_fingerprint = Some("synthetic-root-b".to_owned()); + conflicting_root.relative_path = + Some("evidence/client-app-enforce/root-b/lo/AppEnforce.lo_".to_owned()); + conflicting_root_bundle.artifacts.push(conflicting_root); + assert_eq!( + assess_client_intake(&conflicting_root_bundle), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "one lineage and rotation cannot be relabeled as a second configured root" + ); +} + +#[test] +fn rotation_lineage_cannot_cross_path_fingerprints_across_distinct_rotations() { + let mut bundle = load_bundle("rotations"); + bundle.artifacts[1].path_fingerprint = Some("synthetic-root-b".to_owned()); + bundle.artifacts[1].relative_path = + Some("evidence/client-app-enforce/root-b/lo/AppEnforce.lo_".to_owned()); + + assert_eq!( + assess_client_intake(&bundle), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "one immutable lineage cannot combine rotations from distinct configured roots" + ); +} + +#[test] +fn fragment_order_is_source_identity_then_rotation_rank() { + let fixture = load_bundle("rotations"); + let mut root_a_current = fixture.artifacts[0].clone(); + root_a_current.relative_path = + Some("evidence/client-app-enforce/root-a/current/AppEnforce.log".to_owned()); + let mut root_a_lo = fixture.artifacts[1].clone(); + root_a_lo.relative_path = + Some("evidence/client-app-enforce/root-a/lo/AppEnforce.lo_".to_owned()); + + let mut root_b_current = root_a_current.clone(); + root_b_current.artifact.artifact_id = "fixture-rotations-app-enforce-root-b-current".to_owned(); + root_b_current.path_fingerprint = Some("synthetic-root-b".to_owned()); + root_b_current.rotation_lineage = Some("synthetic:app-enforce-root-b".to_owned()); + root_b_current.relative_path = + Some("evidence/client-app-enforce/root-b/current/AppEnforce.log".to_owned()); + + let mut root_b_lo = root_a_lo.clone(); + root_b_lo.artifact.artifact_id = "fixture-rotations-app-enforce-root-b-lo".to_owned(); + root_b_lo.path_fingerprint = Some("synthetic-root-b".to_owned()); + root_b_lo.rotation_lineage = Some("synthetic:app-enforce-root-b".to_owned()); + root_b_lo.relative_path = + Some("evidence/client-app-enforce/root-b/lo/AppEnforce.lo_".to_owned()); + + let bundle = SccmClientIntakeBundle { + artifacts: vec![root_b_lo, root_a_current, root_b_current, root_a_lo], + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).expect("two source lineages are valid"); + let ordered_ids = assessment + .group("client-app-enforce") + .expect("app enforcement group") + .fragments + .iter() + .map(|fragment| fragment.artifact_id.as_str()) + .collect::>(); + + assert_eq!( + ordered_ids, + [ + "fixture-rotations-app-enforce-root-a-current", + "fixture-rotations-app-enforce-root-a-lo", + "fixture-rotations-app-enforce-root-b-current", + "fixture-rotations-app-enforce-root-b-lo", + ], + "stable source/path identity must precede rotation rank" + ); + + let mut reordered = bundle; + reordered.artifacts.reverse(); + assert_eq!( + serde_json::to_string(&assess_client_intake(&reordered).expect("reordered intake")) + .expect("reordered JSON"), + serde_json::to_string(&assessment).expect("assessment JSON"), + "source-first ordering must remain independent of declaration order" + ); +} + +#[test] +fn missing_access_denied_and_capped_sources_remain_exact_coverage_states() { + let missing = assessment("missing-root"); + assert!(missing + .groups + .iter() + .all(|group| group.coverage == SccmCoverageState::Absent)); + assert_eq!( + missing.coverage_gaps.len(), + 19, + "the shared LocationServices declaration contributes one gap to each consumer group, while maintenance, reboot, and extended workflow groups remain explicit" + ); + assert_eq!( + missing + .coverage_gaps + .iter() + .filter_map(|gap| gap.artifact_id.as_deref()) + .collect::>() + .len(), + 11, + "every missing source declaration remains identifiable" + ); + assert!(serde_json::to_string(&missing) + .expect("missing JSON") + .contains("\"coverage\":\"absent\"")); + + let denied = assessment("access-denied"); + assert_eq!( + denied + .group("client-policy-agent") + .expect("policy-agent group") + .coverage, + SccmCoverageState::AccessDenied + ); + assert_eq!( + denied + .group("client-policy-state") + .expect("policy-state group") + .coverage, + SccmCoverageState::Captured + ); + assert!(denied.coverage_gaps.iter().any(|gap| { + gap.logical_artifact_id == "client-policy-agent" + && gap.coverage == SccmCoverageState::AccessDenied + })); + + let capped = assessment("capped"); + let content = capped.group("client-content").expect("content group"); + assert_eq!(content.coverage, SccmCoverageState::Capped); + assert_eq!(content.fragments.len(), 1); + assert_eq!(content.fragments[0].fragment_complete, Some(false)); + assert!(capped.coverage_gaps.iter().any(|gap| { + gap.logical_artifact_id == "client-content" && gap.coverage == SccmCoverageState::Capped + })); +} + +#[test] +fn capped_cas_fragment_cannot_claim_complete() { + let contradictory = SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: "fixture-content-capped".to_owned(), + display_name: "CAS.log".to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.TEST.0000".to_owned()), + collected_at_utc: Some("2026-07-30T00:03:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Capped, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some("synthetic:content-capped".to_owned()), + rotation_lineage: None, + relative_path: Some("evidence/client-content/current/CAS.log".to_owned()), + fragment_complete: Some(true), + declared_byte_length: None, + content_sha256: None, + }; + + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![contradictory], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidFragmentCompleteness), + "a capped physical fragment cannot claim complete public provenance" + ); +} + +#[test] +fn captured_incomplete_fragment_retains_a_boundary_without_becoming_capped() { + let mut boundary = synthetic_artifact("content-a", "CAS.log"); + boundary.relative_path = Some("evidence/client-content/current/CAS.log".to_owned()); + boundary.fragment_complete = Some(false); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![boundary], + capture_gaps: Vec::new(), + }) + .expect("a fully copied rotation may still end on an incomplete logical record"); + let content = intake.group("client-content").expect("content group"); + + assert_eq!(content.coverage, SccmCoverageState::Captured); + assert_eq!(content.fragments[0].coverage, SccmCoverageState::Captured); + assert_eq!(content.fragments[0].fragment_complete, Some(false)); + assert!(intake.coverage_gaps.iter().any(|gap| { + gap.logical_artifact_id == "client-content" + && gap.artifact_id.as_deref() == Some("fixture-content-a") + && gap.coverage == SccmCoverageState::Captured + && gap.reason + == "Client source CAS.log was captured with an incomplete logical-record boundary." + })); +} + +#[test] +fn parse_failed_fragment_completeness_is_intentionally_two_valued() { + // ParseFailed is the one physical state where both completeness values + // carry meaning: `true` records a fully copied source that could not be + // normalized, `false` records a truncated copy that also failed to + // parse. Both must stay representable so neither situation is forced to + // misdeclare itself as the other. + for (fragment_complete, artifact_id) in [(true, "complete"), (false, "incomplete")] { + let mut unparseable = synthetic_artifact(artifact_id, "PolicyAgent.log"); + unparseable.artifact.coverage = SccmCoverageState::ParseFailed; + unparseable.fragment_complete = Some(fragment_complete); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![unparseable], + capture_gaps: Vec::new(), + }) + .unwrap_or_else(|error| { + panic!("parse-failed completeness {fragment_complete} was rejected: {error}") + }); + + let group = intake.group("client-policy-agent").expect("policy group"); + assert_eq!(group.coverage, SccmCoverageState::ParseFailed); + assert_eq!(group.fragments.len(), 1); + assert_eq!( + group.fragments[0].fragment_complete, + Some(fragment_complete), + "declared parse-failed completeness must project unchanged" + ); + let expected_artifact_id = format!("fixture-{artifact_id}"); + let parse_gap = intake + .coverage_gaps + .iter() + .find(|gap| gap.artifact_id.as_deref() == Some(expected_artifact_id.as_str())) + .expect("parse-failed source retains its own coverage gap"); + assert_eq!( + parse_gap.reason, + "Client source PolicyAgent.log could not be normalized as CCM evidence.", + "parse failure wording must remain independent of fragment-boundary completeness" + ); + } +} + +#[test] +fn mixed_captured_and_absent_group_preserves_partial_coverage_and_names_the_absent_source() { + let mut captured = synthetic_artifact("content-a", "CAS.log"); + captured.relative_path = Some("evidence/client-content/current/CAS.log".to_owned()); + let absent = synthetic_marker( + "content-transfer-absent", + "ContentTransferManager.log", + SccmCoverageState::Absent, + ); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![captured, absent], + capture_gaps: Vec::new(), + }) + .expect("a mixed captured and absent client-content group is representable"); + + let group = intake.group("client-content").expect("content group"); + assert_eq!( + group.coverage, + SccmCoverageState::Absent, + "partial source coverage must remain visible at the group boundary" + ); + assert_eq!(group.fragments.len(), 2); + assert_eq!( + intake.physical_artifacts.len(), + 1, + "a non-physical marker must not be published as a physical artifact" + ); + + let gaps: Vec<_> = intake + .coverage_gaps + .iter() + .filter(|gap| gap.logical_artifact_id == "client-content") + .collect(); + assert_eq!(gaps.len(), 1, "one per-source gap for the absent sibling"); + assert_eq!(gaps[0].coverage, SccmCoverageState::Absent); + assert_eq!( + gaps[0].artifact_id.as_deref(), + Some("fixture-content-transfer-absent") + ); + assert_eq!( + gaps[0].reason, + "No artifact for client source ContentTransferManager.log was supplied." + ); + assert!( + gaps.iter() + .all(|gap| gap.reason + != "No artifact for this bounded client source group was supplied."), + "the gap must name the absent source instead of claiming the whole group was unsupplied" + ); +} + +#[test] +fn mixed_captured_and_access_denied_group_preserves_partial_coverage_and_names_the_denied_source() { + let mut captured = synthetic_artifact("content-a", "CAS.log"); + captured.relative_path = Some("evidence/client-content/current/CAS.log".to_owned()); + let mut denied = synthetic_marker( + "content-denied", + "DataTransferService.log", + SccmCoverageState::AccessDenied, + ); + denied.path_fingerprint = Some("synthetic:content-denied".to_owned()); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![captured, denied], + capture_gaps: Vec::new(), + }) + .expect("a mixed captured and access-denied client-content group is representable"); + + let group = intake.group("client-content").expect("content group"); + assert_eq!( + group.coverage, + SccmCoverageState::AccessDenied, + "captured evidence must not erase an access-denied sibling source" + ); + assert_eq!(group.fragments.len(), 2); + assert_eq!(intake.physical_artifacts.len(), 1); + + let gaps: Vec<_> = intake + .coverage_gaps + .iter() + .filter(|gap| gap.logical_artifact_id == "client-content") + .collect(); + assert_eq!(gaps.len(), 1, "one per-source gap for the denied sibling"); + assert_eq!(gaps[0].coverage, SccmCoverageState::AccessDenied); + assert_eq!( + gaps[0].artifact_id.as_deref(), + Some("fixture-content-denied") + ); + assert_eq!( + gaps[0].reason, + "Access was denied for client source DataTransferService.log." + ); +} + +#[test] +fn mixed_capped_and_absent_group_keeps_the_capped_capture_and_names_the_absent_source() { + let mut capped = synthetic_artifact("content-capped", "DataTransferService.log"); + capped.artifact.coverage = SccmCoverageState::Capped; + capped.path_fingerprint = Some("synthetic:content-capped".to_owned()); + capped.relative_path = + Some("evidence/client-content/current/DataTransferService.log".to_owned()); + capped.fragment_complete = Some(false); + let absent = synthetic_marker("content-absent", "CAS.log", SccmCoverageState::Absent); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![capped, absent], + capture_gaps: Vec::new(), + }) + .expect("a mixed capped and absent client-content group is representable"); + + let group = intake.group("client-content").expect("content group"); + assert_eq!( + group.coverage, + SccmCoverageState::Capped, + "Capped outranks Absent in the group coverage severity order" + ); + assert_eq!(group.fragments.len(), 2); + + let gaps: Vec<_> = intake + .coverage_gaps + .iter() + .filter(|gap| gap.logical_artifact_id == "client-content") + .collect(); + assert_eq!( + gaps.len(), + 2, + "the capped and absent sources both retain explicit gaps" + ); + let capped_gap = gaps + .iter() + .find(|gap| gap.artifact_id.as_deref() == Some("fixture-content-capped")) + .expect("capped source gap"); + assert_eq!(capped_gap.coverage, SccmCoverageState::Capped); + assert_eq!( + capped_gap.reason, + "Client source DataTransferService.log reached its capture limit." + ); + let absent_gap = gaps + .iter() + .find(|gap| gap.artifact_id.as_deref() == Some("fixture-content-absent")) + .expect("absent source gap"); + assert_eq!(absent_gap.coverage, SccmCoverageState::Absent); + assert_eq!( + absent_gap.reason, + "No artifact for client source CAS.log was supplied." + ); +} + +#[test] +fn duplicate_nonphysical_markers_for_the_same_source_fail_closed() { + assert_eq!( + SccmClientIntakeError::DuplicateArtifactId.to_string(), + "client intake contains a duplicate artifact ID or source declaration" + ); + + let first = synthetic_marker("missing-one", "PolicyAgent.log", SccmCoverageState::Absent); + let second = synthetic_marker("missing-two", "PolicyAgent.log", SccmCoverageState::Absent); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![first, second], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::DuplicateArtifactId), + "the same missing source must not be double-declared under differing caller labels" + ); + + let absent = synthetic_marker("missing-one", "PolicyAgent.log", SccmCoverageState::Absent); + let denied = synthetic_marker( + "denied-one", + "PolicyAgent.log", + SccmCoverageState::AccessDenied, + ); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![absent, denied], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::DuplicateArtifactId), + "contradictory marker states for the same source must not both project as fragments" + ); +} + +#[test] +fn absent_markers_with_distinct_path_fingerprints_remain_distinct_sources() { + let mut root_a = synthetic_marker( + "missing-root-a", + "PolicyAgent.log", + SccmCoverageState::Absent, + ); + root_a.path_fingerprint = Some("synthetic:policy-root-a".to_owned()); + let mut root_b = synthetic_marker( + "missing-root-b", + "PolicyAgent.log", + SccmCoverageState::Absent, + ); + root_b.path_fingerprint = Some("synthetic:policy-root-b".to_owned()); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![root_a, root_b], + capture_gaps: Vec::new(), + }) + .expect("markers distinguished by explicit path fingerprints stay representable"); + + let group = intake.group("client-policy-agent").expect("policy group"); + assert_eq!(group.coverage, SccmCoverageState::Absent); + assert_eq!( + group.fragments.len(), + 2, + "per-root absence claims with distinct fingerprints are distinct sources" + ); + let gap_artifacts = intake + .coverage_gaps + .iter() + .filter(|gap| gap.logical_artifact_id == "client-policy-agent") + .filter_map(|gap| gap.artifact_id.as_deref()) + .collect::>(); + assert_eq!( + gap_artifacts, + BTreeSet::from(["fixture-missing-root-a", "fixture-missing-root-b"]), + "every configured root must retain its own explicit coverage gap" + ); +} + +#[test] +fn unpinned_marker_for_a_physically_declared_source_fails_closed() { + // Round-6 review repro: one captured PolicyAgent.log plus one absent + // marker for the same source (current rotation, no path fingerprint) + // previously produced an assessment claiming no artifact for the source + // was supplied while serializing the captured PolicyAgent.log fragment. + // The marker's canonical identity must intersect the physical + // declaration for the source and fail closed instead. + let captured = synthetic_artifact("policy", "PolicyAgent.log"); + let absent = synthetic_marker("missing-one", "PolicyAgent.log", SccmCoverageState::Absent); + + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![captured.clone(), absent.clone()], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "an unpinned absent marker for a captured source is a self-contradiction" + ); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![absent, captured], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "declaration order must not reopen the marker-versus-physical collision" + ); +} + +#[test] +fn pinned_markers_for_distinct_roots_coexist_with_physical_evidence() { + // The basename and rotation are not a complete source identity when + // both declarations carry distinct configured-root fingerprints. + for marker_coverage in [ + SccmCoverageState::Absent, + SccmCoverageState::AccessDenied, + SccmCoverageState::Skipped, + ] { + let captured = synthetic_artifact("policy", "PolicyAgent.log"); + let mut pinned = + synthetic_marker("missing-one", "PolicyAgent.log", marker_coverage.clone()); + pinned.path_fingerprint = Some("synthetic:policy-root-b".to_owned()); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![captured.clone(), pinned.clone()], + capture_gaps: Vec::new(), + }) + .expect("distinct configured roots remain distinct sources"); + assert_eq!(intake.physical_artifacts.len(), 1); + assert_eq!( + intake + .group("client-policy-agent") + .expect("policy group") + .coverage, + marker_coverage + ); + + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![pinned, captured], + capture_gaps: Vec::new(), + }) + .expect("declaration order must not collapse distinct roots"); + } + + let mut capped = synthetic_artifact("capped-one", "PolicyAgent.log"); + capped.artifact.coverage = SccmCoverageState::Capped; + capped.fragment_complete = Some(false); + let mut pinned = synthetic_marker("missing-one", "PolicyAgent.log", SccmCoverageState::Absent); + pinned.path_fingerprint = Some("synthetic:policy-root-b".to_owned()); + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![capped, pinned], + capture_gaps: Vec::new(), + }) + .expect("a capped root and absent sibling root are both preserved"); + assert_eq!( + intake + .group("client-policy-agent") + .expect("policy group") + .coverage, + SccmCoverageState::Capped + ); +} + +#[test] +fn a_marker_cannot_reuse_the_physical_source_fingerprint() { + let captured = synthetic_artifact("policy", "PolicyAgent.log"); + let mut marker = synthetic_marker("missing-one", "PolicyAgent.log", SccmCoverageState::Absent); + marker.path_fingerprint = captured.path_fingerprint.clone(); + + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![captured, marker], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::CollidingPhysicalIdentity) + ); +} + +#[test] +fn unpinned_and_pinned_markers_for_the_same_source_fail_closed() { + // A fingerprint-less marker claims the whole declared source, so it + // must collide with any other declaration for that source identity, + // including a marker pinned to one configured root. The sibling server + // intake removes this ambiguity by making the path fingerprint + // mandatory on every declaration; the client contract keeps optional + // marker fingerprints for the committed all-absent fixture bundles, so + // identity intersection is the fail-closed equivalent here. Documented + // #319 native-manifest follow-up: reassess whether legacy marker mapping + // can converge the client contract on mandatory fingerprints and remove + // the remaining client/server asymmetry. + let unpinned = synthetic_marker("missing-one", "PolicyAgent.log", SccmCoverageState::Absent); + let mut pinned = synthetic_marker("missing-two", "PolicyAgent.log", SccmCoverageState::Absent); + pinned.path_fingerprint = Some("synthetic:policy-root-a".to_owned()); + + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![unpinned.clone(), pinned.clone()], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::DuplicateArtifactId), + "an unpinned and a pinned marker must not double-declare one source" + ); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![pinned, unpinned], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::DuplicateArtifactId), + "declaration order must not reopen the marker double-declaration" + ); +} + +#[test] +fn markers_for_distinct_rotations_of_a_captured_source_remain_representable() { + // The collision is scoped to one source identity: a marker for a + // genuinely distinct source (here the numbered rotation of the same + // basename) still coexists with the captured current rotation and + // surfaces as a per-source gap that the fragments array corroborates. + let captured = synthetic_artifact("policy", "PolicyAgent.log"); + let mut rotated_absent = synthetic_marker( + "missing-one", + "PolicyAgent.log.2", + SccmCoverageState::Absent, + ); + rotated_absent.artifact.rotation = SccmRotation::Numbered(2); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![captured, rotated_absent], + capture_gaps: Vec::new(), + }) + .expect("a marker for a distinct rotation of a captured source stays representable"); + + let group = intake.group("client-policy-agent").expect("policy group"); + assert_eq!(group.coverage, SccmCoverageState::Absent); + assert_eq!(group.fragments.len(), 2); + + let gaps: Vec<_> = intake + .coverage_gaps + .iter() + .filter(|gap| gap.logical_artifact_id == "client-policy-agent") + .collect(); + assert_eq!(gaps.len(), 1, "one per-source gap for the absent rotation"); + assert_eq!(gaps[0].coverage, SccmCoverageState::Absent); + assert_eq!(gaps[0].artifact_id.as_deref(), Some("fixture-missing-one")); + assert_eq!( + gaps[0].reason, + "No artifact for client source PolicyAgent.log.2 was supplied." + ); +} + +#[test] +fn basename_collisions_preserve_distinct_artifacts_and_bundle_paths() { + let intake = assessment("collision"); + let group = intake + .group("client-app-enforce") + .expect("app enforcement group"); + assert_eq!(group.fragments.len(), 2); + assert_eq!( + group + .fragments + .iter() + .map(|fragment| fragment.artifact_id.as_str()) + .collect::>() + .len(), + 2 + ); + assert_eq!( + group + .fragments + .iter() + .filter_map(|fragment| fragment.relative_path.as_deref()) + .collect::>() + .len(), + 2 + ); +} + +#[test] +fn unknown_and_lookalike_names_are_retained_as_unsupported_not_reclassified() { + let bundle = SccmClientIntakeBundle { + artifacts: vec![ + synthetic_artifact("custom", "CustomVendorHook.log"), + synthetic_artifact("lookalike", "PolicyAgent.log.backup"), + synthetic_artifact("unknown-lo", "CustomVendorHook.lo_"), + ], + capture_gaps: Vec::new(), + }; + let intake = assess_client_intake(&bundle).expect("unknown intake"); + + assert_eq!(intake.unsupported_artifacts.len(), 3); + assert!(intake + .unsupported_artifacts + .iter() + .all(|unknown| unknown.classification == SccmCoverageState::Unsupported)); + assert!(intake + .group("client-policy-agent") + .expect("policy group") + .fragments + .is_empty()); +} + +#[test] +fn malformed_rotation_and_public_provenance_values_fail_closed() { + // The relative path is consistent with the declared rotation so the + // malformed timestamp grammar is the only contract this input violates. + let mut invalid_rotation = synthetic_artifact("invalid-rotation", "AppEnforce.log.2026-bad"); + invalid_rotation.artifact.rotation = SccmRotation::Timestamped("2026-bad".to_owned()); + invalid_rotation.relative_path = + Some("evidence/client-app-enforce/timestamped-2026-bad/AppEnforce.log.2026-bad".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![invalid_rotation], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidRotation), + "a malformed rotation timestamp must fail on the rotation contract" + ); + + let mut unsafe_basename = + synthetic_artifact("unsafe-basename", r"C:\Users\RealUser\PolicyAgent.log"); + unsafe_basename.relative_path = + Some("evidence/unknown/unsafe-basename/PolicyAgent.log".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![unsafe_basename], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidBasename), + "a path-bearing basename must fail on the basename contract" + ); + + let mut invalid_time = synthetic_artifact("invalid-time", "PolicyAgent.log"); + invalid_time.artifact.collected_at_utc = Some(r"C:\Users\RealUser".to_owned()); + invalid_time.relative_path = Some("evidence/client-policy-agent/PolicyAgent.log".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![invalid_time], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidCollectedAt), + "a non-RFC-3339 collection timestamp must fail on the timestamp contract" + ); + + let mut invalid_version = synthetic_artifact("invalid-version", "PolicyAgent.log"); + invalid_version.artifact.configmgr_version = Some("5.00.TEST/C:\\RealUser".to_owned()); + invalid_version.relative_path = Some("evidence/client-policy-agent/PolicyAgent.log".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![invalid_version], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidConfigMgrVersion), + "an unsafe ConfigMgr version must fail on the version contract" + ); +} + +#[test] +fn configmgr_version_and_encoding_use_bounded_public_grammars() { + for version in ["5.00.9128.1007", "5.00.TEST.0000", "5.00.UNKNOWN.0000"] { + let mut artifact = synthetic_artifact("valid-version", "PolicyAgent.log"); + artifact.artifact.configmgr_version = Some(version.to_owned()); + assert!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }) + .is_ok(), + "documented ConfigMgr version {version} should remain representable" + ); + } + + for version in [ + "realuser", + "corp-example-test", + "domain-example-test", + r"5.00.C:\Users\RealUser", + "5.00.TEST.\0", + ] { + let mut artifact = synthetic_artifact("invalid-version", "PolicyAgent.log"); + artifact.artifact.configmgr_version = Some(version.to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidConfigMgrVersion), + "unsafe ConfigMgr version {version:?} must fail closed" + ); + } + + for encoding in ["utf-8", "utf-16le", "utf-16be", "windows-1252"] { + let mut artifact = synthetic_artifact("valid-version", "PolicyAgent.log"); + artifact.artifact.encoding = Some(encoding.to_owned()); + assert!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }) + .is_ok(), + "supported encoding {encoding} should remain representable" + ); + } + + for encoding in [ + "realuser", + "corp-example-test", + "domain-example-test", + r"C:\Users\RealUser", + "utf-8\0realuser", + ] { + let mut artifact = synthetic_artifact("invalid-version", "PolicyAgent.log"); + artifact.artifact.encoding = Some(encoding.to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidEncoding), + "unsafe encoding {encoding:?} must fail closed" + ); + } +} + +#[test] +fn unknown_rotation_public_metadata_is_versioned_and_opaque() { + let opaque_handle = format!("sha256:{}", "a".repeat(64)); + for kind in [ + "realuser".to_owned(), + "corp-example-test".to_owned(), + "realuser.example.com".to_owned(), + r"C:\Users\RealUser".to_owned(), + "cmtraceopen.rotation.opaque.v1\0".to_owned(), + "x".repeat(129), + ] { + let mut artifact = synthetic_artifact("invalid-rotation", "PolicyAgent.log"); + artifact.artifact.rotation = SccmRotation::Unknown(SccmUnknownRotation { + kind, + value: Some(serde_json::json!("opaque-v1")), + }); + artifact.relative_path = Some("evidence/unknown/PolicyAgent.log".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidRotation) + ); + } + + for value in [ + serde_json::json!("realuser"), + serde_json::json!("corp-example-test"), + serde_json::json!("realuser.example.com"), + serde_json::json!(r"C:\Users\RealUser"), + serde_json::json!("opaque\0realuser"), + serde_json::json!("x".repeat(129)), + serde_json::json!(123456789), + serde_json::json!({"opaque": "realuser"}), + ] { + let mut artifact = synthetic_artifact("invalid-rotation", "PolicyAgent.log"); + artifact.artifact.rotation = SccmRotation::Unknown(SccmUnknownRotation { + kind: "cmtraceopen.rotation.opaque.v1".to_owned(), + value: Some(value), + }); + artifact.relative_path = Some("evidence/unknown/PolicyAgent.log".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidRotation) + ); + } + + let mut future = synthetic_artifact("custom", "PolicyAgent.log"); + future.artifact.rotation = SccmRotation::Unknown(SccmUnknownRotation { + kind: "cmtraceopen.rotation.opaque.v1".to_owned(), + value: Some(serde_json::json!(opaque_handle)), + }); + future.relative_path = Some("evidence/unknown/PolicyAgent.log".to_owned()); + let assessed = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![future], + capture_gaps: Vec::new(), + }) + .expect("versioned opaque future rotation remains representable"); + assert_eq!(assessed.unsupported_artifacts.len(), 1); +} + +#[test] +fn distinct_opaque_unknown_rotations_do_not_collapse_to_one_source_identity() { + let mut captured = synthetic_artifact("unknown-a", "PolicyAgent.log"); + captured.artifact.rotation = SccmRotation::Unknown(SccmUnknownRotation { + kind: "cmtraceopen.rotation.opaque.v1".to_owned(), + value: Some(serde_json::json!(format!("sha256:{}", "a".repeat(64)))), + }); + captured.relative_path = Some("evidence/unknown/PolicyAgent.log".to_owned()); + + let mut unavailable = synthetic_marker( + "unknown-b", + "PolicyAgent.log", + SccmCoverageState::AccessDenied, + ); + unavailable.artifact.rotation = SccmRotation::Unknown(SccmUnknownRotation { + kind: "cmtraceopen.rotation.opaque.v1".to_owned(), + value: Some(serde_json::json!(format!("sha256:{}", "b".repeat(64)))), + }); + + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![captured, unavailable], + capture_gaps: Vec::new(), + }) + .expect("distinct opaque rotations remain distinct declarations"); + + assert_eq!(intake.unsupported_artifacts.len(), 2); + assert_ne!( + intake.unsupported_artifacts[0].rotation, + intake.unsupported_artifacts[1].rotation + ); +} + +#[test] +fn caller_controlled_public_identity_channels_fail_closed() { + for artifact_id in [ + "client-realuser", + "client-corp-example-test", + "realuser", + "fixture-123-45-6789", + ] { + let mut artifact = synthetic_artifact("invalid-artifact", "PolicyAgent.log"); + artifact.artifact.artifact_id = artifact_id.to_owned(); + artifact.path_fingerprint = Some("synthetic:policy-current".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidArtifactId), + "identity-bearing artifact ID {artifact_id:?} reached public output" + ); + } + + for basename in [ + "RealUser.log", + "corp-example-test.log", + "realuser.example.test.log", + ] { + let mut artifact = synthetic_artifact("custom", basename); + artifact.relative_path = Some(format!("evidence/unknown/current/{basename}")); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidBasename), + "identity-bearing unsupported basename {basename:?} reached public output" + ); + } + + for relative_path in [ + "evidence/client-policy-agent/current/RealUser.log", + "evidence/client-policy-agent/current/corp-example-test.log", + "evidence/client-content/current/PolicyAgent.log", + "evidence/client-policy-agent/lo/PolicyAgent.log", + ] { + let mut artifact = synthetic_artifact("invalid-relative", "PolicyAgent.log"); + artifact.relative_path = Some(relative_path.to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidRelativePath), + "relative path was not bound to its canonical source: {relative_path:?}" + ); + } + + let mut mixed_case = synthetic_artifact("invalid-basename", "policyagent.log"); + mixed_case.relative_path = + Some("evidence/client-policy-agent/current/policyagent.log".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![mixed_case], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidBasename), + "supported source names must use their exact canonical spelling" + ); +} + +#[test] +fn public_identity_contract_retains_only_reviewed_synthetic_and_opaque_forms() { + let mut native = synthetic_artifact("valid-version", "PolicyAgent.log"); + native.artifact.artifact_id = format!("sccm-artifact:v1:sha256:{}", "a".repeat(64)); + assert!(assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![native], + capture_gaps: Vec::new(), + }) + .is_ok()); + + let opaque_basename = format!("sccm-unknown-v1-sha256-{}.log", "b".repeat(64)); + let mut unknown = synthetic_artifact("custom", &opaque_basename); + unknown.relative_path = Some(format!("evidence/unknown/current/{opaque_basename}")); + let unknown = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![unknown], + capture_gaps: Vec::new(), + }) + .expect("opaque unsupported source remains representable"); + assert_eq!(unknown.unsupported_artifacts.len(), 1); + + let mut raw_context = synthetic_artifact("valid-version", "PolicyAgent.log"); + raw_context.artifact.original_path = Some(r"C:\Users\RealUser\PolicyAgent.log".to_owned()); + raw_context.artifact.host = Some("host-only-sentinel.corp.example.test".to_owned()); + let serialized = serde_json::to_string( + &assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![raw_context], + capture_gaps: Vec::new(), + }) + .expect("raw native context is intentionally not projected"), + ) + .expect("assessment serializes"); + let serialized_casefolded = serialized.to_ascii_lowercase(); + assert!(!serialized_casefolded.contains("realuser")); + assert!(!serialized_casefolded.contains("host-only-sentinel")); + assert!(!serialized_json_contains_windows_user_root( + &serialized_casefolded + )); + + let mut oversized_timestamp = synthetic_artifact("invalid-time", "PolicyAgent.log"); + oversized_timestamp.artifact.collected_at_utc = + Some(format!("2026-07-30T00:00:00.{}Z", "1".repeat(256))); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![oversized_timestamp], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidCollectedAt) + ); +} + +#[test] +fn fragment_completeness_and_every_path_fingerprint_are_explicit_and_unambiguous() { + let mut missing_completeness = synthetic_artifact("missing-completeness", "PolicyAgent.log"); + missing_completeness.relative_path = + Some("evidence/client-policy-agent/PolicyAgent.log".to_owned()); + missing_completeness.fragment_complete = None; + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![missing_completeness], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::MissingFragmentCompleteness), + "fragment completeness must be an explicit declaration" + ); + + // A non-physical marker keeping fragmentComplete=true invents a physical + // capture it does not have; markers carry no bytes that could be + // complete, so the completeness contract is the check that fails. + let mut invented_physical_state = synthetic_artifact("denied", "PolicyAgent.log"); + invented_physical_state.artifact.coverage = SccmCoverageState::AccessDenied; + invented_physical_state.relative_path = None; + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![invented_physical_state], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidFragmentCompleteness), + "a marker claiming a complete fragment invents a physical capture" + ); + + // The mirror image: a physical capture stripped of its collision-safe + // bundle path must fail on the provenance contract. + let mut missing_provenance = synthetic_artifact("missing-path", "PolicyAgent.log"); + missing_provenance.relative_path = None; + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![missing_provenance], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::MissingPhysicalProvenance), + "a physical capture without its bundle path lacks required provenance" + ); + + let mut first = synthetic_artifact("denied-one", "PolicyAgent.log"); + first.artifact.coverage = SccmCoverageState::AccessDenied; + first.relative_path = None; + first.fragment_complete = Some(false); + let mut second = synthetic_artifact("denied-two", "CIAgent.log"); + second.artifact.coverage = SccmCoverageState::AccessDenied; + second.relative_path = None; + second.fragment_complete = Some(false); + second.path_fingerprint = first.path_fingerprint.clone(); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![first, second], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "two markers must not share one path fingerprint" + ); +} + +#[test] +fn unsupported_physical_artifacts_retain_safe_provenance_without_raw_host_or_path() { + let mut artifact = synthetic_artifact("custom", "CustomVendorHook.log"); + artifact.artifact.original_path = Some(r"C:\Users\RealUser\CustomVendorHook.log".to_owned()); + artifact.artifact.host = Some("real-user-host.example".to_owned()); + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }) + .expect("unknown physical artifact remains representable"); + let serialized = serde_json::to_string(&intake).expect("intake JSON"); + let serialized_casefolded = serialized.to_ascii_lowercase(); + + // The positive assertions deliberately keep the original case: the + // projection must reproduce the declared basename exactly. Every leak + // assertion below is casefolded instead, so a projection that + // normalized case could not smuggle an identity past this site. + assert!(serialized.contains("synthetic-custom")); + assert!(serialized.contains("evidence/unknown/CustomVendorHook.log")); + assert!(!serialized_casefolded.contains("realuser")); + assert!(!serialized_casefolded.contains("real-user-host")); + assert!(!serialized_json_contains_windows_user_root( + &serialized_casefolded + )); +} + +#[test] +fn ambiguous_identity_or_nonclient_role_fails_closed() { + // The collision fixture declares one basename under two configured roots, + // so each artifact stays individually well formed and only the identity + // channel under test collides. + let mut duplicate = load_bundle("collision"); + duplicate.artifacts[1].artifact.artifact_id = + duplicate.artifacts[0].artifact.artifact_id.clone(); + assert_eq!( + assess_client_intake(&duplicate), + Err(SccmClientIntakeError::DuplicateArtifactId), + "two artifacts must not share one caller identity" + ); + + let mut duplicate_path = load_bundle("collision"); + duplicate_path.artifacts[1].relative_path = duplicate_path.artifacts[0].relative_path.clone(); + assert_eq!( + assess_client_intake(&duplicate_path), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "two captures must not share one bundle-relative evidence path" + ); + + let mut wrong_role = load_bundle("complete"); + wrong_role.artifacts[0].artifact.role = SccmRole::ManagementPoint; + assert_eq!( + assess_client_intake(&wrong_role), + Err(SccmClientIntakeError::RoleMismatch), + "client intake must reject a non-client role outright" + ); +} + +#[test] +fn identity_bearing_relative_paths_fail_before_public_projection() { + let unsafe_relative_paths = [ + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-RealUser/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-corp-example-test/PolicyAgent.log", + ), + ( + "AppEnforce.log", + SccmRotation::Current, + "evidence/client-app-enforce/root-realuser/current/AppEnforce.log", + ), + ( + "AppEnforce.log", + SccmRotation::Current, + "evidence/client-app-enforce/root-corp-example-test/current/AppEnforce.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/RealUser@example.test/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/Users/RealUser/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/home/real-user/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/corp.example.test/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/profile=RealUser/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/LAB%5CRealUser/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/Real User/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/résumé-real-user/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/\0/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/../PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/..", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/.", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "/evidence/client-policy-agent/current/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/C:/Users/RealUser/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/LAB\\RealUser/PolicyAgent.log", + ), + ]; + + for (display_name, rotation, relative_path) in unsafe_relative_paths { + let mut artifact = synthetic_artifact("unsafe-relative", display_name); + artifact.artifact.rotation = rotation; + artifact.path_fingerprint = Some("synthetic:policy-current".to_owned()); + artifact.relative_path = Some(relative_path.to_owned()); + + let result = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }); + assert!( + matches!(result, Err(SccmClientIntakeError::InvalidRelativePath)), + "identity-bearing path reached the public assessment: {relative_path:?} => {result:?}" + ); + } + + let mut malformed_timestamp = + synthetic_artifact("unsafe-relative", "AppEnforce.log.20241340-296199"); + malformed_timestamp.artifact.rotation = SccmRotation::Timestamped("20241340-296199".to_owned()); + malformed_timestamp.path_fingerprint = Some("synthetic:app-enforce-current".to_owned()); + malformed_timestamp.relative_path = Some( + "evidence/client-app-enforce/timestamped-20241340-296199/AppEnforce.log.20241340-296199" + .to_owned(), + ); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![malformed_timestamp], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidRotation), + "malformed timestamp rotation must fail on its own metadata contract" + ); +} + +#[test] +fn rotation_lineage_is_versioned_privacy_safe_and_bound_to_one_source() { + let digest = "a".repeat(64); + let mut opaque = synthetic_artifact("policy-a", "PolicyAgent.log"); + opaque.rotation_lineage = Some(format!("cmtraceopen.lineage.sha256.v1:{digest}")); + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![opaque], + capture_gaps: Vec::new(), + }) + .expect("the versioned opaque lineage form is accepted"); + + for lineage in [ + "", + "synthetic:free-form-user-value", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "cmtraceopen.lineage.sha256.v1:short", + "cmtraceopen.lineage.sha256.v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + r"C:\Users\RealUser\CCM\Logs", + ] { + let mut artifact = synthetic_artifact("policy-a", "PolicyAgent.log"); + artifact.rotation_lineage = Some(lineage.to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidRotationLineage), + "unsafe lineage reached the public projection: {lineage:?}" + ); + } + + let mut policy = synthetic_artifact("policy-a", "PolicyAgent.log"); + policy.rotation_lineage = Some("synthetic:policy-root-a".to_owned()); + let mut state = synthetic_artifact("state-b", "CIAgent.log"); + state.rotation_lineage = Some("synthetic:policy-root-a".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![policy, state], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "one immutable lineage cannot be rebound to a different catalog source" + ); +} + +#[test] +fn shared_location_services_path_binding_preserves_every_canonical_rotation() { + let rotations = [ + ("LocationServices.log", SccmRotation::Current, "current"), + ("LocationServices.lo_", SccmRotation::LoUnderscore, "lo"), + ( + "LocationServices.log.2", + SccmRotation::Numbered(2), + "numbered-2", + ), + ( + "LocationServices.log.20260730-030405", + SccmRotation::Timestamped("20260730-030405".to_owned()), + "timestamped-20260730-030405", + ), + ]; + + for (display_name, rotation, rotation_segment) in rotations { + let mut artifact = synthetic_artifact("valid-location", display_name); + artifact.artifact.rotation = rotation; + artifact.path_fingerprint = Some("synthetic:location-services-current".to_owned()); + artifact.relative_path = Some(format!( + "evidence/client-location-services-shared/{rotation_segment}/{display_name}" + )); + + let assessment = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }) + .unwrap_or_else(|error| { + panic!( + "canonical shared LocationServices rotation was rejected: {display_name}: {error}" + ) + }); + + assert_eq!(assessment.physical_artifacts.len(), 1); + assert_eq!( + assessment + .group("client-content") + .expect("content group") + .fragments + .len(), + 1 + ); + assert_eq!( + assessment + .group("client-location") + .expect("location group") + .fragments + .len(), + 1 + ); + } +} + +#[test] +fn unsafe_path_fingerprints_fail_before_public_projection() { + let unsafe_fingerprints = [ + "realuser", + "corp-example-test", + "domain-example-test", + "md5:0123456789abcdef", + "sha256:not-a-hex-handle", + "synthetic:realuser", + "synthetic:RealUser", + "synthetic:corp-example-test", + "synthetic-RealUser", + "synthetic:123:45:6789", + "synthetic-123-45-6789", + "synthetic\0raw-user", + "synthetic\u{7f}raw-user", + "synthetic-résumé-user", + "synthetic=raw-user", + "synthetic%5craw-user", + "synthetic/raw-user", + "synthetic\\raw-user", + "synthetic@raw-user", + "synthetic raw-user", + ]; + + for fingerprint in unsafe_fingerprints { + let mut artifact = synthetic_artifact("unsafe-fingerprint", "PolicyAgent.log"); + artifact.relative_path = + Some("evidence/client-policy-agent/current/PolicyAgent.log".to_owned()); + artifact.path_fingerprint = Some(fingerprint.to_owned()); + + let result = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }); + assert!( + matches!(result, Err(SccmClientIntakeError::InvalidPathFingerprint)), + "unsafe fingerprint reached the public assessment: {fingerprint:?} => {result:?}" + ); + } +} + +#[test] +fn sha256_path_fingerprints_require_exactly_64_lowercase_hex_characters() { + for digest in [ + "a".repeat(16), + "a".repeat(63), + "a".repeat(65), + "A".repeat(64), + "g".repeat(64), + ] { + let mut artifact = synthetic_artifact("unsafe-fingerprint", "PolicyAgent.log"); + artifact.path_fingerprint = Some(format!("sha256:{digest}")); + + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidPathFingerprint), + "invalid SHA-256 digest was accepted: {digest:?}" + ); + } + + let mut artifact = synthetic_artifact("approved-fingerprint", "PolicyAgent.log"); + artifact.path_fingerprint = Some(format!("sha256:{}", "a".repeat(64))); + assert!(assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }) + .is_ok()); +} + +#[test] +fn numbered_synthetic_path_fingerprints_accept_only_a_short_numeric_suffix() { + let mut numbered = synthetic_artifact("approved-fingerprint", "AppEnforce.log.3"); + numbered.artifact.rotation = SccmRotation::Numbered(3); + numbered.path_fingerprint = Some("synthetic:app-enforce-numbered-3".to_owned()); + numbered.relative_path = + Some("evidence/client-app-enforce/numbered-3/AppEnforce.log.3".to_owned()); + assert!(assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![numbered], + capture_gaps: Vec::new(), + }) + .is_ok()); + + let mut oversized = synthetic_artifact("unsafe-fingerprint", "AppEnforce.log"); + oversized.path_fingerprint = Some("synthetic:app-enforce-numbered-123".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![oversized], + capture_gaps: Vec::new(), + }), + Err(SccmClientIntakeError::InvalidPathFingerprint) + ); +} + +#[test] +fn approved_namespaced_path_fingerprints_remain_accepted() { + let approved_fingerprints = [ + "synthetic-root-a-current", + "synthetic:policy-current", + "synthetic:path:client-root-a", + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ]; + + for fingerprint in approved_fingerprints { + let mut artifact = synthetic_artifact("approved-fingerprint", "PolicyAgent.log"); + artifact.relative_path = + Some("evidence/client-policy-agent/current/PolicyAgent.log".to_owned()); + artifact.path_fingerprint = Some(fingerprint.to_owned()); + + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }) + .unwrap_or_else(|error| panic!("approved fingerprint {fingerprint:?} failed: {error}")); + } +} + +#[test] +fn approved_collision_safe_relative_layouts_remain_accepted() { + let approved_relative_paths = [ + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/PolicyAgent.log", + ), + ( + "PolicyAgent.log", + SccmRotation::Current, + "evidence/client-policy-agent/current/PolicyAgent.log", + ), + ( + "LocationServices.log", + SccmRotation::Current, + "evidence/client-location-services-shared/current/LocationServices.log", + ), + ( + "AppEnforce.log", + SccmRotation::Current, + "evidence/client-app-enforce/root-a/current/AppEnforce.log", + ), + ( + "AppEnforce.log", + SccmRotation::Current, + "evidence/client-app-enforce/root-0123456789abcdef/current/AppEnforce.log", + ), + ( + "AppEnforce.log.2", + SccmRotation::Numbered(2), + "evidence/client-app-enforce/numbered-2/AppEnforce.log.2", + ), + ( + "AppEnforce.log.20260730-030405", + SccmRotation::Timestamped("20260730-030405".to_owned()), + "evidence/client-app-enforce/timestamped-20260730-030405/AppEnforce.log.20260730-030405", + ), + ( + "AppEnforce.log", + SccmRotation::Current, + "evidence/sccm/client/client-app-enforce/current/AppEnforce.log", + ), + ( + "CustomVendorHook.log", + SccmRotation::Current, + "evidence/unknown/CustomVendorHook.log", + ), + ]; + + for (display_name, rotation, relative_path) in approved_relative_paths { + let mut artifact = synthetic_artifact("approved-relative", display_name); + artifact.artifact.rotation = rotation; + artifact.path_fingerprint = Some("synthetic:policy-current".to_owned()); + artifact.relative_path = Some(relative_path.to_owned()); + + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + capture_gaps: Vec::new(), + }) + .unwrap_or_else(|error| panic!("approved path {relative_path:?} failed: {error}")); + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake_fixture_contract.rs new file mode 100644 index 000000000..bd4fe5783 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake_fixture_contract.rs @@ -0,0 +1,161 @@ +use cmtraceopen_parser::{ + models::log_entry::LogFormat, + parser::{parse_content_with_selection, ResolvedParser}, +}; +use serde_json::Value; + +const CAPPED_CONTENT: &[u8] = include_bytes!( + "fixtures/sccm/client/intake/capped/evidence/client-content/current/DataTransferService.log" +); +const EXPECTED_CAPPED_CONTENT: &[u8] = b" bool { + site_code == "LAB" +} + +#[test] +fn client_intake_rejects_non_lab_three_character_site_codes() { + assert!(!site_code_is_canonical("ABC")); +} + +#[test] +fn capped_client_content_is_an_exact_incomplete_ccm_prefix() { + assert_eq!(CAPPED_CONTENT.len(), 128); + assert_eq!( + CAPPED_CONTENT, EXPECTED_CAPPED_CONTENT, + "fixture bytes must retain SHA-256 {EXPECTED_CAPPED_SHA256}" + ); + + let content = std::str::from_utf8(CAPPED_CONTENT).expect("fixture is declared UTF-8"); + assert!(content.starts_with(" = rotations["artifacts"] + .as_array() + .expect("rotation artifacts are an array") + .iter() + .filter(|artifact| artifact["rotation"]["kind"] == "lo") + .collect(); + if rollovers.len() != 1 { + failures.push(format!( + "rotations: expected exactly one .lo_ artifact, got {}", + rollovers.len() + )); + } + let Some(rollover) = rollovers.first() else { + assert!(failures.is_empty(), "{}", failures.join("\n")); + return; + }; + let basename = rollover["originalBasename"] + .as_str() + .expect("rollover artifact has an original basename"); + if basename != "AppEnforce.lo_" { + failures.push(format!( + "rotations: standard ConfigMgr rollover basename must be AppEnforce.lo_, got {basename}" + )); + } + + let relative_path = rollover["relativePath"] + .as_str() + .expect("captured rollover has a relative path"); + if relative_path != EXPECTED_ROLLOVER_RELATIVE_PATH { + failures.push(format!( + "rotations: rollover relativePath must be {EXPECTED_ROLLOVER_RELATIVE_PATH}, got {relative_path}" + )); + } + let sanitized_path = rollover["sanitizedSourcePath"] + .as_str() + .expect("rollover artifact has sanitized provenance"); + if sanitized_path != EXPECTED_ROLLOVER_SANITIZED_PATH { + failures.push(format!( + "rotations: rollover sanitizedSourcePath must be {EXPECTED_ROLLOVER_SANITIZED_PATH}, got {sanitized_path}" + )); + } + let bytes_copied = rollover["bytesCopied"] + .as_u64() + .expect("captured rollover records bytesCopied"); + if bytes_copied != EXPECTED_ROLLOVER_BYTES { + failures.push(format!( + "rotations: bytesCopied must be the committed {EXPECTED_ROLLOVER_BYTES}, got {bytes_copied}" + )); + } + let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/client/intake/rotations") + .join(relative_path); + if !fixture_path.is_file() { + failures.push(format!( + "rotations: manifest relativePath does not resolve to a fixture: {}", + fixture_path.display() + )); + } else { + let actual_bytes = std::fs::metadata(&fixture_path) + .expect("rollover fixture metadata is readable") + .len(); + if bytes_copied != actual_bytes { + failures.push(format!( + "rotations: bytesCopied {bytes_copied} does not match fixture length {actual_bytes}" + )); + } + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs new file mode 100644 index 000000000..7f6921467 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs @@ -0,0 +1,813 @@ +use cmtraceopen_parser::sccm::client::{ + admit_client_evidence, analyze_client_extended, assess_client_intake, + SccmClientCapturedPayload, SccmClientExtendedState, SccmClientExtendedWorkflow, + SccmClientIntakeArtifact, SccmClientIntakeBundle, +}; +use cmtraceopen_parser::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; + +struct Source<'a> { + id: &'a str, + basename: &'a str, + component: &'a str, + coverage: SccmCoverageState, + records: Vec<(&'a str, &'a str)>, +} + +fn source_group(basename: &str) -> &'static str { + match basename { + "InventoryAgent.log" | "InventoryProvider.log" | "InventoryAgentProvider.log" => { + "client-inventory" + } + "CIAgent.log" | "StateMessage.log" => "client-policy-state", + "CITaskMgr.log" | "DCMAgent.log" | "DCMReporting.log" => "client-compliance", + "SWMTRReportGen.log" => "client-metering", + _ => panic!("unexpected extended source {basename}"), + } +} + +fn admitted( + sources: Vec>, +) -> cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence { + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + for source in sources { + let artifact_id = format!("fixture-{}", source.id); + let bytes = source + .records + .iter() + .map(|(time, message)| { + format!( + "\n", + source.component + ) + }) + .collect::() + .into_bytes(); + let captured = source.coverage == SccmCoverageState::Captured; + artifacts.push(SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: source.basename.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T23:59:59Z".to_owned()), + rotation: SccmRotation::Current, + coverage: source.coverage, + encoding: captured.then(|| "utf-8".to_owned()), + }, + path_fingerprint: Some(format!("synthetic-{}", source.id)), + rotation_lineage: None, + relative_path: captured.then(|| { + format!( + "evidence/{}/current/{}", + source_group(source.basename), + source.basename + ) + }), + fragment_complete: Some(captured), + declared_byte_length: captured.then_some(bytes.len() as u64), + content_sha256: captured.then(|| { + Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + }), + }); + if captured { + payloads + .push(SccmClientCapturedPayload::new(artifact_id, bytes).expect("bounded payload")); + } + } + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).expect("canonical intake"); + admit_client_evidence(&bundle, &assessment, &payloads).expect("sealed evidence") +} + +fn corpus_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/client/inventory-compliance-metering") +} + +fn corpus_scenarios() -> Vec<(String, PathBuf)> { + let mut scenarios = Vec::new(); + for workflow in ["inventory", "compliance", "metering"] { + let workflow_root = corpus_root().join(workflow); + for entry in fs::read_dir(&workflow_root).expect("workflow corpus directory") { + let entry = entry.expect("scenario entry"); + if entry.file_type().expect("scenario type").is_dir() { + scenarios.push(( + format!("{workflow}/{}", entry.file_name().to_string_lossy()), + entry.path(), + )); + } + } + } + scenarios.sort_by(|left, right| left.0.cmp(&right.0)); + scenarios +} + +struct CorpusAdmission { + admitted: cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence, + artifact_ids: BTreeMap, +} + +fn corpus_admitted(scenario_dir: &Path) -> Result { + let manifest: serde_json::Value = serde_json::from_slice( + &fs::read(scenario_dir.join("manifest.json")).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + let mut artifact_ids = BTreeMap::new(); + for (index, source) in manifest["artifacts"] + .as_array() + .ok_or("manifest artifacts missing")? + .iter() + .enumerate() + { + let coverage = match source["captureState"].as_str().ok_or("capture state")? { + "captured" => SccmCoverageState::Captured, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "capped" => SccmCoverageState::Capped, + "skipped" => SccmCoverageState::Skipped, + "unsupported" => SccmCoverageState::Unsupported, + "parseFailed" => SccmCoverageState::ParseFailed, + other => return Err(format!("unsupported coverage {other}")), + }; + let rotation = match source["rotation"]["kind"].as_str().ok_or("rotation kind")? { + "current" => SccmRotation::Current, + "lo" => SccmRotation::LoUnderscore, + other => return Err(format!("unsupported rotation {other}")), + }; + let manifest_basename = source["originalBasename"] + .as_str() + .ok_or("original basename")?; + let normalized_rotation_basename = manifest_basename + .strip_suffix(".log.lo") + .map(|stem| format!("{stem}.lo_")); + let basename = normalized_rotation_basename + .as_deref() + .unwrap_or(manifest_basename); + let preparation_group = source["designOnlyCatalog"]["entryId"] + .as_str() + .ok_or("source group")?; + let group = if matches!(basename, "CIAgent.log" | "StateMessage.log") { + "client-policy-state" + } else { + preparation_group + }; + let fragment_complete = source["rotation"]["fragmentComplete"] + .as_bool() + .unwrap_or(false); + let source_version = source["sourceVersion"].as_str(); + let preparation_artifact_id = source["artifactId"].as_str().unwrap_or_default(); + let artifact_id = format!("fixture-update-numbered-{:02}", index + 1); + artifact_ids.insert(preparation_artifact_id.to_owned(), artifact_id.clone()); + let payload_bytes = (coverage == SccmCoverageState::Captured && fragment_complete) + .then(|| { + let relative_path = source["relativePath"] + .as_str() + .ok_or("captured relative path")?; + fs::read(scenario_dir.join(relative_path)).map_err(|error| error.to_string()) + }) + .transpose()?; + let physical = matches!( + coverage, + SccmCoverageState::Captured + | SccmCoverageState::Capped + | SccmCoverageState::ParseFailed + ); + let rotation_segment = if matches!(rotation, SccmRotation::LoUnderscore) { + "lo" + } else { + "current" + }; + let root_identity = source["sanitizedSourcePath"] + .as_str() + .and_then(|path| path.strip_prefix("SYNTHETIC://")) + .and_then(|path| path.split('/').next()) + .unwrap_or(preparation_artifact_id); + let root_digest: String = Sha256::digest(root_identity.as_bytes()) + .iter() + .take(8) + .map(|byte| format!("{byte:02x}")) + .collect(); + artifacts.push(SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: basename.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: source_version.map(|version| { + if version == "5.00.TEST.325" { + "5.00.9128.1000".to_owned() + } else { + version.to_owned() + } + }), + collected_at_utc: source["capturedUtc"].as_str().map(str::to_owned), + rotation, + coverage, + encoding: source["encoding"].as_str().map(str::to_owned), + }, + path_fingerprint: Some(format!("synthetic-update-numbered-{:02}", index + 1)), + rotation_lineage: None, + relative_path: physical.then(|| { + format!("evidence/{group}/root-{root_digest}/{rotation_segment}/{basename}") + }), + fragment_complete: Some(fragment_complete), + declared_byte_length: payload_bytes.as_ref().map(|bytes| bytes.len() as u64), + content_sha256: payload_bytes.as_ref().map(|bytes| { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + }), + }); + if let Some(bytes) = payload_bytes { + payloads.push( + SccmClientCapturedPayload::new(artifact_id, bytes) + .map_err(|error| error.to_string())?, + ); + } + } + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + for artifact in &bundle.artifacts { + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact.clone()], + capture_gaps: Vec::new(), + }) + .map_err(|error| format!("{}: {error}", artifact.artifact.artifact_id))?; + } + let assessment = assess_client_intake(&bundle).map_err(|error| error.to_string())?; + let admitted = admit_client_evidence(&bundle, &assessment, &payloads) + .map_err(|error| error.to_string())?; + Ok(CorpusAdmission { + admitted, + artifact_ids, + }) +} + +fn translate_admitted_artifact_ids( + value: &mut serde_json::Value, + artifact_ids: &BTreeMap, +) { + match value { + serde_json::Value::String(text) => { + let mut translations = artifact_ids.iter().collect::>(); + translations.sort_by_key(|(_, admitted)| std::cmp::Reverse(admitted.len())); + for (fixture, admitted) in translations { + *text = text.replace(admitted, fixture); + } + } + serde_json::Value::Array(values) => { + for value in values { + translate_admitted_artifact_ids(value, artifact_ids); + } + } + serde_json::Value::Object(fields) => { + for value in fields.values_mut() { + translate_admitted_artifact_ids(value, artifact_ids); + } + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + } +} + +fn canonicalize_json(value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.into_iter().map(canonicalize_json).collect()) + } + serde_json::Value::Object(fields) => { + let mut entries = fields.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + serde_json::Value::Object( + entries + .into_iter() + .map(|(key, value)| (key, canonicalize_json(value))) + .collect(), + ) + } + scalar => scalar, + } +} + +fn production_output_digest( + analysis: &cmtraceopen_parser::sccm::client::SccmClientExtendedAnalysis, + artifact_ids: &BTreeMap, +) -> String { + let mut normalized = serde_json::to_value(analysis).expect("serializable production analysis"); + translate_admitted_artifact_ids(&mut normalized, artifact_ids); + let normalized = canonicalize_json(normalized); + Sha256::digest(serde_json::to_vec(&normalized).expect("canonical production JSON")) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[test] +fn separates_inventory_compliance_and_metering_transactions() { + let evidence = admitted(vec![ + Source { + id: "update-a", + basename: "InventoryAgentProvider.log", + component: "InventoryAgentProvider", + coverage: SccmCoverageState::Captured, + records: vec![("01:00:00.000", "Family=inventory InventoryCycleId=INV-CYCLE-001 ResourceHandle=safe:resource:inventory-001 ReportId=INV-REPORT-001 Phase=Report Disposition=Succeeded Terminal=true")], + }, + Source { + id: "update-b", + basename: "DCMReporting.log", + component: "DCMReporting", + coverage: SccmCoverageState::Captured, + records: vec![("01:00:01.000", "Family=compliance CiId=CI-001 BaselineId=BASELINE-001 StateId=STATE-001 ResourceHandle=safe:resource:compliance-001 Phase=Report Disposition=Succeeded ResultType=Evaluation Terminal=true")], + }, + Source { + id: "update-state", + basename: "StateMessage.log", + component: "StateMessage", + coverage: SccmCoverageState::Captured, + records: vec![("01:00:01.500", "Family=compliance CiId=CI-001 BaselineId=BASELINE-001 StateId=STATE-001 ResourceHandle=safe:resource:compliance-001 Phase=Report Disposition=Succeeded ResultType=Evaluation Terminal=true")], + }, + Source { + id: "update-c", + basename: "SWMTRReportGen.log", + component: "SWMTRReportGen", + coverage: SccmCoverageState::Captured, + records: vec![("01:00:02.000", "Family=metering MeteringCycleId=METER-CYCLE-001 RuleId=RULE-001 ReportId=METER-REPORT-001 ResourceHandle=safe:resource:metering-001 Phase=Report Disposition=Succeeded Terminal=true")], + }, + ]); + + let result = analyze_client_extended(&evidence).expect("extended analysis"); + assert_eq!(result.transactions.len(), 3); + assert_eq!( + result + .transactions + .iter() + .map(|transaction| transaction.workflow) + .collect::>(), + vec![ + SccmClientExtendedWorkflow::Inventory, + SccmClientExtendedWorkflow::Compliance, + SccmClientExtendedWorkflow::Metering, + ] + ); + assert!(result + .transactions + .iter() + .all(|transaction| transaction.state == SccmClientExtendedState::Succeeded)); + assert!(result + .transactions + .iter() + .all(|transaction| transaction.keys.len() >= 3)); +} + +#[test] +fn joins_recovery_only_with_the_same_complete_key_tuple_and_ordering() { + let evidence = admitted(vec![Source { + id: "update-a", + basename: "InventoryAgentProvider.log", + component: "InventoryAgentProvider", + coverage: SccmCoverageState::Captured, + records: vec![ + ("02:00:00.000", "InventoryCycleId=INV-CYCLE-002 ResourceHandle=safe:resource:inventory-002 ReportId=INV-REPORT-002 Phase=Report Disposition=Failed Terminal=true"), + ("02:00:01.000", "InventoryCycleId=INV-CYCLE-002 ResourceHandle=safe:resource:inventory-002 ReportId=INV-REPORT-002 Phase=Report Disposition=Succeeded Terminal=true"), + ("02:00:02.000", "InventoryCycleId=INV-CYCLE-003 ResourceHandle=safe:resource:inventory-003 ReportId=INV-REPORT-003 Phase=Report Disposition=Failed Terminal=true"), + ("02:00:02.000", "InventoryCycleId=INV-CYCLE-003 ResourceHandle=safe:resource:inventory-003 ReportId=INV-REPORT-003 Phase=Report Disposition=Succeeded Terminal=true"), + ], + }]); + + let result = analyze_client_extended(&evidence).expect("extended analysis"); + assert_eq!(result.transactions.len(), 2); + assert_eq!( + result.transactions[0].state, + SccmClientExtendedState::Recovered + ); + assert_eq!( + result.transactions[1].state, + SccmClientExtendedState::Contradictory + ); +} + +#[test] +fn keeps_missing_sources_and_keys_as_explicit_gaps() { + let evidence = admitted(vec![ + Source { + id: "absent", + basename: "CIAgent.log", + component: "CIAgent", + coverage: SccmCoverageState::Absent, + records: Vec::new(), + }, + Source { + id: "agent-a", + basename: "InventoryAgent.log", + component: "InventoryAgent", + coverage: SccmCoverageState::Captured, + records: vec![( + "03:00:00.000", + "InventoryCycleId=INV-CYCLE-004 Phase=Collect Disposition=Succeeded Terminal=false", + )], + }, + ]); + + let result = analyze_client_extended(&evidence).expect("extended analysis"); + assert!(result.transactions.is_empty()); + assert_eq!(result.coverage.len(), 2); + assert_eq!( + result + .coverage + .iter() + .find(|coverage| coverage.source.artifact_id == "fixture-absent") + .expect("compliance coverage") + .source + .coverage, + SccmCoverageState::Absent + ); + assert_eq!(result.source_local_observations.len(), 2); + assert!(result.source_local_observations.iter().any(|observation| { + observation + .sources + .iter() + .map(|source| source.artifact_id.as_str()) + .collect::>() + == ["fixture-absent"] + && observation.evidence.is_empty() + })); + assert!(result.source_local_observations.iter().any(|observation| { + observation + .sources + .iter() + .map(|source| source.artifact_id.as_str()) + .collect::>() + == ["fixture-agent-a"] + && observation.evidence.len() == 1 + })); +} + +#[test] +fn component_spoofing_cannot_cross_the_sealed_physical_source_boundary() { + let evidence = admitted(vec![Source { + id: "agent-a", + basename: "InventoryAgent.log", + component: "DCMReporting", + coverage: SccmCoverageState::Captured, + records: vec![("04:00:00.000", "CiId=CI-001 BaselineId=BASELINE-001 StateId=STATE-001 ResourceHandle=safe:resource:compliance-001 Phase=Report Disposition=Failed Terminal=true")], + }]); + + let result = analyze_client_extended(&evidence).expect("extended analysis"); + assert!(result.transactions.is_empty()); + assert!(result.findings.is_empty()); +} + +#[test] +fn all_committed_extended_scenarios_execute_the_exported_analyzer() { + let scenarios = corpus_scenarios(); + assert_eq!( + scenarios.len(), + 21, + "the complete committed corpus executes" + ); + + for (scenario, scenario_dir) in scenarios { + let expected: serde_json::Value = serde_json::from_slice( + &fs::read(scenario_dir.join("expected.json")).expect("scenario expected contract"), + ) + .expect("valid expected contract"); + if scenario == "compliance/malformed-unknown-profile-invalid-offset" { + match corpus_admitted(&scenario_dir) { + Ok(_) => panic!("{scenario}: invalid time and unknown profile were admitted"), + Err(error) => { + assert!( + expected["productionOutputSha256"].is_null(), + "{scenario}: rejected input has no production output" + ); + assert_eq!( + expected["productionAdmissionError"].as_str(), + Some(error.as_str()), + "{scenario}: exact committed admission rejection" + ); + } + } + continue; + } + let corpus = corpus_admitted(&scenario_dir) + .unwrap_or_else(|error| panic!("{scenario}: sealed corpus admission: {error}")); + let analysis = analyze_client_extended(&corpus.admitted) + .unwrap_or_else(|error| panic!("{scenario}: exported analyzer: {error}")); + let actual = serde_json::to_value(&analysis).expect("serializable production analysis"); + let repeated = serde_json::to_value( + analyze_client_extended(&corpus.admitted).expect("repeat production analysis"), + ) + .expect("serializable repeated analysis"); + assert_eq!(actual, repeated, "{scenario}: full output is deterministic"); + assert!( + expected["productionAdmissionError"].is_null(), + "{scenario}: admitted input has no admission rejection" + ); + let actual_digest = production_output_digest(&analysis, &corpus.artifact_ids); + assert_eq!( + expected["productionOutputSha256"].as_str(), + Some(actual_digest.as_str()), + "{scenario}: complete normalized production output" + ); + assert_eq!(actual["schemaVersion"], 1, "{scenario}: schema"); + let expected_transactions = expected["transactions"] + .as_array() + .expect("expected transactions"); + assert_eq!( + analysis.transactions.len(), + expected_transactions.len(), + "{scenario}: transaction count" + ); + + let mut transaction_ids = std::collections::BTreeSet::new(); + for expected_transaction in expected_transactions { + let workflow = expected_transaction["workflow"].as_str().expect("workflow"); + let key_labels: &[&str] = match workflow { + "inventory" => &["InventoryCycleId", "ResourceHandle", "ReportId"], + "compliance" => &["CiId", "BaselineId", "StateId", "ResourceHandle"], + "metering" => &["MeteringCycleId", "RuleId", "ReportId", "ResourceHandle"], + other => panic!("{scenario}: unknown workflow {other}"), + }; + let expected_values = key_labels + .iter() + .map(|label| { + expected_transaction["key"][label] + .as_str() + .expect("key value") + }) + .collect::>(); + let transaction = analysis + .transactions + .iter() + .find(|transaction| { + transaction + .keys + .iter() + .map(|key| key.normalized.as_str()) + .collect::>() + == expected_values + }) + .unwrap_or_else(|| panic!("{scenario}: exact expected key tuple missing")); + let serialized = serde_json::to_value(transaction).expect("serialized transaction"); + assert_eq!( + serialized["workflow"].as_str(), + Some(workflow), + "{scenario}: workflow" + ); + assert_eq!( + serialized["phase"].as_str().map(str::to_ascii_lowercase), + expected_transaction["phase"] + .as_str() + .map(str::to_ascii_lowercase), + "{scenario}: phase" + ); + assert_eq!( + serialized["state"].as_str().map(str::to_ascii_lowercase), + expected_transaction["state"] + .as_str() + .map(str::to_ascii_lowercase), + "{scenario}: state" + ); + assert_eq!( + transaction.profile_id, + "sccm-keys-5.00.9128-experimental-v1" + ); + let discriminator = transaction + .transaction_id + .rsplit(':') + .next() + .expect("digest"); + assert_eq!( + discriminator.len(), + 64, + "{scenario}: full SHA-256 discriminator" + ); + assert!(discriminator.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!( + transaction_ids.insert(transaction.transaction_id.clone()), + "{scenario}: unique transaction id" + ); + + let expected_evidence = expected_transaction["evidence"] + .as_array() + .expect("expected evidence") + .iter() + .map(|reference| { + ( + corpus.artifact_ids[reference["artifactId"].as_str().expect("artifact id")] + .as_str(), + Some(reference["startLine"].as_u64().expect("start line")), + Some(reference["endLine"].as_u64().expect("end line")), + ) + }) + .collect::>(); + let actual_evidence = transaction + .evidence + .iter() + .map(|reference| { + ( + reference.artifact_id.as_str(), + reference.line_start.map(u64::from), + reference.line_end.map(u64::from), + ) + }) + .collect::>(); + assert_eq!( + actual_evidence, expected_evidence, + "{scenario}: exact evidence citations" + ); + + let expected_gap_ids = expected_transaction["coverageGapArtifactIds"] + .as_array() + .expect("expected gaps") + .iter() + .map(|id| corpus.artifact_ids[id.as_str().expect("gap id")].clone()) + .collect::>(); + assert_eq!( + transaction.coverage_gap_artifact_ids, expected_gap_ids, + "{scenario}: exact phase-source coverage gaps" + ); + + let abnormal = matches!( + expected_transaction["state"].as_str(), + Some( + "failed" + | "evaluatedNonCompliant" + | "blockedOrDeferred" + | "contradictory" + | "insufficientEvidence" + ) + ); + let finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == transaction.transaction_id); + assert_eq!( + finding.is_some(), + abnormal, + "{scenario}: finding presence follows committed expected state" + ); + if let Some(finding) = finding { + let request = finding + .next_artifact + .as_ref() + .expect("abnormal outcome has exact next artifact"); + assert_eq!(request.source_basename, transaction.source_basename); + assert!(!request.logical_artifact_id.is_empty()); + assert!(!request.reason.is_empty()); + if expected_transaction["nextArtifact"].is_object() { + assert_eq!( + request.logical_artifact_id, + expected_transaction["nextArtifact"]["logicalArtifactId"] + .as_str() + .unwrap() + ); + assert_eq!( + request.source_basename, + expected_transaction["nextArtifact"]["sourceBasename"] + .as_str() + .unwrap() + ); + assert_eq!( + request.reason, + expected_transaction["nextArtifact"]["reason"] + .as_str() + .unwrap() + ); + } + } + } + + let mut expected_coverage = expected["coverage"] + .as_array() + .expect("expected coverage") + .iter() + .map(|item| { + ( + corpus.artifact_ids[item["artifactId"].as_str().unwrap()].clone(), + item["state"].as_str().unwrap().to_ascii_lowercase(), + ) + }) + .collect::>(); + let mut actual_coverage = analysis + .coverage + .iter() + .map(|item| { + ( + item.source.artifact_id.clone(), + if item.source.coverage == SccmCoverageState::Captured + && !item.source.fragment_complete + { + "partial".to_owned() + } else { + serde_json::to_value(item.source.coverage.clone()) + .unwrap() + .as_str() + .unwrap() + .to_ascii_lowercase() + }, + ) + }) + .collect::>(); + expected_coverage.sort(); + actual_coverage.sort(); + assert_eq!( + actual_coverage, expected_coverage, + "{scenario}: exact artifact-level coverage" + ); + + let mut expected_observation_ids = expected["sourceLocalObservations"] + .as_array() + .expect("expected observations") + .iter() + .flat_map(|observation| { + observation["artifactIds"] + .as_array() + .into_iter() + .flatten() + .map(|id| corpus.artifact_ids[id.as_str().unwrap()].clone()) + }) + .collect::>(); + let mut actual_observation_ids = analysis + .source_local_observations + .iter() + .flat_map(|observation| { + observation + .sources + .iter() + .map(|source| source.artifact_id.clone()) + }) + .collect::>(); + expected_observation_ids.sort(); + expected_observation_ids.dedup(); + actual_observation_ids.sort(); + actual_observation_ids.dedup(); + assert_eq!( + actual_observation_ids, expected_observation_ids, + "{scenario}: source-local observations cite exact sources" + ); + for observation in &analysis.source_local_observations { + assert!( + !observation.sources.is_empty(), + "{scenario}: source citation" + ); + assert!(observation.evidence.iter().all(|reference| observation + .sources + .iter() + .any(|source| source.artifact_id == reference.artifact_id))); + } + assert_eq!( + analysis.prohibited_claims, + [ + "server root cause", + "time-only cross-artifact causality", + "native Windows acceptance", + ] + ); + } +} + +#[test] +fn duplicate_and_case_variant_semantic_labels_are_rejected_as_ambiguous() { + let evidence = admitted(vec![Source { + id: "update-a", + basename: "InventoryAgent.log", + component: "InventoryAgent", + coverage: SccmCoverageState::Captured, + records: vec![( + "05:00:00.000", + "InventoryCycleId=INV-DUP-001 inventorycycleid=INV-DUP-002 ResourceHandle=safe:resource:dup ReportId=REPORT-DUP Phase=Collect phase=Report Disposition=Succeeded Terminal=true", + )], + }]); + + let analysis = analyze_client_extended(&evidence).expect("extended analysis"); + assert!(analysis.transactions.is_empty()); + assert!(analysis.findings.is_empty()); + assert_eq!(analysis.source_local_observations.len(), 1); + assert!(analysis.source_local_observations[0] + .reason + .contains("repeats a field label")); + assert_eq!( + analysis.source_local_observations[0].sources[0].artifact_id, + "fixture-update-a" + ); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_inventory_compliance_metering_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_inventory_compliance_metering_fixture_contract.rs new file mode 100644 index 000000000..45b672167 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_inventory_compliance_metering_fixture_contract.rs @@ -0,0 +1,4451 @@ +use cmtraceopen_parser::sccm::{ + normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmRole, SccmRotation, + SccmTimeOrderingState, SccmTimestamp, SccmUnknownRotation, +}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const INVENTORY_SCENARIOS: [&str; 6] = [ + "coverage-states", + "recovery-contradictory", + "rotation-boundary", + "same-minute-collision", + "success", + "terminal-failures", +]; + +const COMPLIANCE_SCENARIOS: [&str; 8] = [ + "coverage-states", + "malformed-unknown-profile-invalid-offset", + "noncompliant-result", + "recovery-contradictory", + "remediation-success", + "same-minute-collision", + "success", + "terminal-failures", +]; + +const METERING_SCENARIOS: [&str; 7] = [ + "coverage-states", + "deferred", + "recovery-contradictory", + "rotation-boundary", + "same-minute-collision", + "success", + "terminal-failures", +]; + +const DOCUMENTED_CORPUS_DIGEST: &str = "76504021b1fb7e87"; + +#[derive(Debug, PartialEq, Eq)] +struct CorpusInventory { + scenarios: usize, + artifacts: usize, + evidence_files: usize, + evidence_bytes: u64, + capture_states: BTreeMap, + digest: String, +} + +fn corpus_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/client/inventory-compliance-metering") +} + +fn directory_names(root: &Path) -> Vec { + let mut names = std::fs::read_dir(root) + .unwrap_or_else(|error| panic!("{} exists and is readable: {error}", root.display())) + .map(|entry| entry.expect("fixture directory entry is readable").path()) + .filter(|path| path.is_dir()) + .map(|path| { + path.file_name() + .expect("fixture directory has a name") + .to_string_lossy() + .into_owned() + }) + .collect::>(); + names.sort(); + names +} + +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 family_scenarios() -> [(&'static str, &'static [&'static str]); 3] { + [ + ("inventory", INVENTORY_SCENARIOS.as_slice()), + ("compliance", COMPLIANCE_SCENARIOS.as_slice()), + ("metering", METERING_SCENARIOS.as_slice()), + ] +} + +fn hex_bytes(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn fnv1a64(bytes: &[u8]) -> String { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +fn corpus_inventory() -> CorpusInventory { + let mut artifacts = 0; + let mut evidence_files = 0; + let mut evidence_bytes = 0; + let mut capture_states = BTreeMap::new(); + let mut digest_rows = Vec::new(); + + for (family, scenarios) in family_scenarios() { + for scenario in scenarios { + let (scenario_root, manifest, _) = load_contract(family, scenario); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + artifacts += 1; + let capture_state = artifact["captureState"] + .as_str() + .expect("captureState is a string"); + *capture_states.entry(capture_state.to_owned()).or_insert(0) += 1; + let Some(relative_path) = artifact["relativePath"].as_str() else { + continue; + }; + let bytes = std::fs::read(scenario_root.join(relative_path)) + .expect("evidence bytes are readable"); + evidence_files += 1; + evidence_bytes += bytes.len() as u64; + digest_rows.push(format!( + "{family}/{scenario}\0{}\0{relative_path}\0{}\n", + artifact["artifactId"] + .as_str() + .expect("artifactId is a string"), + hex_bytes(&bytes) + )); + } + } + } + digest_rows.sort(); + + CorpusInventory { + scenarios: 21, + artifacts, + evidence_files, + evidence_bytes, + capture_states, + digest: fnv1a64(digest_rows.concat().as_bytes()), + } +} + +fn load_contract(family: &str, scenario: &str) -> (PathBuf, Value, Value) { + let scenario_root = corpus_root().join(family).join(scenario); + ( + scenario_root.clone(), + load_json(&scenario_root.join("manifest.json")), + load_json(&scenario_root.join("expected.json")), + ) +} + +fn required_key_fields(family: &str) -> Result<&'static [&'static str], String> { + match family { + "inventory" => Ok(&["InventoryCycleId", "ResourceHandle", "ReportId"]), + "compliance" => Ok(&["CiId", "BaselineId", "StateId", "ResourceHandle"]), + "metering" => Ok(&["MeteringCycleId", "RuleId", "ReportId", "ResourceHandle"]), + other => Err(format!("unsupported workflow family {other}")), + } +} + +fn admitted_sources(family: &str) -> Result<&'static [&'static str], String> { + match family { + "inventory" => Ok(&[ + "InventoryAgent.log", + "InventoryProvider.log", + "InventoryAgentProvider.log", + ]), + "compliance" => Ok(&[ + "CIAgent.log", + "CITaskMgr.log", + "DCMAgent.log", + "DCMReporting.log", + "StateMessage.log", + ]), + "metering" => Ok(&["SWMTRReportGen.log"]), + other => Err(format!("unsupported workflow family {other}")), + } +} + +fn admitted_phases(family: &str) -> Result<&'static [&'static str], String> { + match family { + "inventory" => Ok(&["Collect", "Provider", "Serialize", "Queue", "Report"]), + "compliance" => Ok(&["Evaluate", "Remediate", "Report"]), + "metering" => Ok(&["Collect", "Aggregate", "Report"]), + other => Err(format!("unsupported workflow family {other}")), + } +} + +fn admitted_source_phases( + family: &str, + source_basename: &str, +) -> Result<&'static [&'static str], String> { + match (family, source_basename) { + ("inventory", "InventoryAgent.log") => Ok(&["Collect"]), + ("inventory", "InventoryProvider.log") => Ok(&["Provider", "Serialize"]), + ("inventory", "InventoryAgentProvider.log") => Ok(&["Queue", "Report"]), + ("compliance", "CIAgent.log" | "CITaskMgr.log") => Ok(&["Evaluate"]), + ("compliance", "DCMAgent.log") => Ok(&["Remediate"]), + ("compliance", "DCMReporting.log") => Ok(&["Evaluate", "Report"]), + ("compliance", "StateMessage.log") => Ok(&["Report"]), + ("metering", "SWMTRReportGen.log") => Ok(&["Collect", "Aggregate", "Report"]), + _ => Err(format!( + "{source_basename} has no admitted {family} phase ownership" + )), + } +} + +fn admitted_structured_fields(family: &str) -> Result, String> { + let fields = match family { + "inventory" => &[ + "Family", + "InventoryCycleId", + "ResourceHandle", + "ReportId", + "Phase", + "Disposition", + "Terminal", + "ErrorCode", + "Recovery", + "Ordering", + "Coverage", + "Rotation", + ][..], + "compliance" => &[ + "Family", + "CiId", + "BaselineId", + "StateId", + "ResourceHandle", + "Phase", + "Disposition", + "Terminal", + "ResultType", + "ErrorCode", + "Recovery", + "Ordering", + "PostRemediation", + "Coverage", + "Rotation", + ][..], + "metering" => &[ + "Family", + "MeteringCycleId", + "RuleId", + "ReportId", + "ResourceHandle", + "Phase", + "Disposition", + "Terminal", + "ErrorCode", + "Recovery", + "Ordering", + "Coverage", + "Rotation", + ][..], + other => return Err(format!("unsupported structured-field family {other}")), + }; + Ok(fields.iter().copied().collect()) +} + +fn expected_logical_artifact(family: &str) -> Result<&'static str, String> { + match family { + "inventory" => Ok("client-inventory"), + "compliance" => Ok("client-compliance"), + "metering" => Ok("client-metering"), + other => Err(format!("unsupported workflow family {other}")), + } +} + +fn validate_structured_field_vocabulary( + fields: &BTreeMap, + context: &str, +) -> Result<(), String> { + let family = fields + .get("Family") + .ok_or_else(|| format!("{context} has no structured Family field"))?; + let admitted = admitted_structured_fields(family)?; + for field in fields.keys() { + if !admitted.contains(field.as_str()) { + return Err(format!( + "{context} has unadmitted structured field {field} for {family}" + )); + } + } + Ok(()) +} + +fn validate_cited_record_semantics( + fields: &BTreeMap, + source_basename: &str, + context: &str, +) -> Result<(), String> { + let family = fields + .get("Family") + .ok_or_else(|| format!("{context} has no structured Family field"))?; + let phase = fields + .get("Phase") + .ok_or_else(|| format!("{context} has no structured Phase field"))?; + if !admitted_source_phases(family, source_basename)?.contains(&phase.as_str()) { + return Err(format!( + "{context} source {source_basename} does not own phase {phase}" + )); + } + let disposition = fields + .get("Disposition") + .ok_or_else(|| format!("{context} has no structured Disposition field"))?; + let terminal = fields + .get("Terminal") + .ok_or_else(|| format!("{context} has no structured Terminal field"))?; + if !matches!(terminal.as_str(), "true" | "false") { + return Err(format!("{context} has invalid Terminal={terminal}")); + } + if !matches!( + disposition.as_str(), + "Succeeded" | "Failed" | "Progress" | "Pending" | "Deferred" | "Compliant" | "NonCompliant" + ) { + return Err(format!( + "{context} has unadmitted Disposition={disposition}" + )); + } + + let evaluation_disposition = matches!(disposition.as_str(), "Compliant" | "NonCompliant"); + if evaluation_disposition && (family != "compliance" || phase != "Evaluate") { + return Err(format!( + "{context} borrows compliance evaluation semantics outside compliance/Evaluate" + )); + } + if let Some(result_type) = fields.get("ResultType") { + if family != "compliance" || phase != "Evaluate" || result_type != "Evaluation" { + return Err(format!( + "{context} has unowned ResultType={result_type} semantics" + )); + } + } + if fields.contains_key("ErrorCode") && (disposition != "Failed" || terminal != "true") { + return Err(format!( + "{context} ErrorCode is not bound to a terminal failure" + )); + } + if fields.contains_key("Recovery") && (disposition != "Succeeded" || terminal != "true") { + return Err(format!( + "{context} Recovery is not bound to terminal success" + )); + } + if fields.contains_key("Ordering") + && (!matches!(disposition.as_str(), "Succeeded" | "Compliant") || terminal != "true") + { + return Err(format!( + "{context} Ordering is not bound to opposing terminal evidence" + )); + } + if let Some(post_remediation) = fields.get("PostRemediation") { + if family != "compliance" + || phase != "Report" + || disposition != "Succeeded" + || terminal != "true" + || post_remediation != "Compliant" + { + return Err(format!( + "{context} has unowned PostRemediation={post_remediation} semantics" + )); + } + } + Ok(()) +} + +fn expected_profile(family: &str) -> Result<&'static str, String> { + match family { + "inventory" => Ok("sccm-client-inventory-5.00.test-v1"), + "compliance" => Ok("sccm-client-compliance-5.00.test-v1"), + "metering" => Ok("sccm-client-metering-5.00.test-v1"), + other => Err(format!("unsupported workflow family {other}")), + } +} + +fn required_scenario_semantics( + family: &str, + scenario: &str, +) -> Result<&'static [&'static str], String> { + match (family, scenario) { + ("inventory", "success") => Ok(&["Report|succeeded|success"]), + ("inventory", "terminal-failures") => Ok(&[ + "Collect|failed|confirmedFailure", + "Provider|failed|confirmedFailure", + "Serialize|failed|confirmedFailure", + "Queue|failed|confirmedFailure", + "Report|failed|confirmedFailure", + ]), + ("inventory", "recovery-contradictory") => { + Ok(&["Report|recovered|recovery", "Report|contradictory|symptom"]) + } + ("inventory", "same-minute-collision") => { + Ok(&["Report|succeeded|success", "Report|succeeded|success"]) + } + ("inventory", "coverage-states" | "rotation-boundary") => Ok(&[]), + ("compliance", "success") => Ok(&["Report|succeeded|success"]), + ("compliance", "noncompliant-result") => { + Ok(&["Evaluate|evaluatedNonCompliant|evaluationResult"]) + } + ("compliance", "remediation-success") => Ok(&["Report|remediated|success"]), + ("compliance", "terminal-failures") => Ok(&[ + "Evaluate|failed|confirmedFailure", + "Remediate|failed|confirmedFailure", + "Report|failed|confirmedFailure", + ]), + ("compliance", "recovery-contradictory") => Ok(&[ + "Report|recovered|recovery", + "Evaluate|contradictory|symptom", + ]), + ("compliance", "same-minute-collision") => Ok(&[ + "Evaluate|evaluatedNonCompliant|evaluationResult", + "Evaluate|evaluatedCompliant|evaluationResult", + ]), + ("compliance", "coverage-states" | "malformed-unknown-profile-invalid-offset") => Ok(&[]), + ("metering", "success") => Ok(&["Report|succeeded|success"]), + ("metering", "deferred") => Ok(&["Report|blockedOrDeferred|blockedOrDeferred"]), + ("metering", "terminal-failures") => Ok(&[ + "Collect|failed|confirmedFailure", + "Aggregate|failed|confirmedFailure", + "Report|failed|confirmedFailure", + ]), + ("metering", "recovery-contradictory") => { + Ok(&["Report|recovered|recovery", "Report|contradictory|symptom"]) + } + ("metering", "same-minute-collision") => { + Ok(&["Report|succeeded|success", "Report|succeeded|success"]) + } + ("metering", "coverage-states" | "rotation-boundary") => Ok(&[]), + _ => Err(format!( + "required scenario semantics are undefined for {family}/{scenario}" + )), + } +} + +fn required_string<'a>(value: &'a Value, field: &str, context: &str) -> Result<&'a str, String> { + value[field] + .as_str() + .ok_or_else(|| format!("{context} {field} is not a string")) +} + +fn validate_canonical_id(value: &str, field: &str, required_prefix: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 128 + || !value.starts_with(required_prefix) + || value.ends_with('-') + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(format!("{field} is not canonical for {required_prefix}")); + } + Ok(()) +} + +fn validate_source_version(version: &str, artifact_id: &str) -> Result<(), String> { + if version.is_empty() + || version.len() > 64 + || version.split('.').any(|segment| { + segment.is_empty() + || segment.starts_with('-') + || segment.ends_with('-') + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(format!("{artifact_id} sourceVersion is not canonical")); + } + Ok(()) +} + +fn require_exact_object_fields( + value: &Value, + expected_fields: &[&str], + context: &str, +) -> Result<(), String> { + let object = value + .as_object() + .ok_or_else(|| format!("{context} is not an object"))?; + let actual = object.keys().map(String::as_str).collect::>(); + let expected = expected_fields.iter().copied().collect::>(); + if actual != expected { + return Err(format!( + "{context} fields {actual:?} are not exact {expected:?}" + )); + } + Ok(()) +} + +fn require_canonical_string_field_order( + rows: &[Value], + field: &str, + context: &str, +) -> Result<(), String> { + let mut previous = None; + for row in rows { + let current = required_string(row, field, context)?; + if previous.is_some_and(|value| value >= current) { + return Err(format!("{context} order is not canonical by {field}")); + } + previous = Some(current); + } + Ok(()) +} + +fn expected_observation_claim(family: &str, kind: &str) -> Result<&'static str, String> { + match (family, kind) { + ("inventory", "coverageGap") => Ok( + "All non-complete source states remain coverage only; no workflow outcome is inferred.", + ), + ("compliance", "coverageGap") => Ok( + "All non-complete source states remain coverage only; no compliance outcome is inferred.", + ), + ("metering", "coverageGap") => Ok( + "All non-complete source states remain coverage only; no metering outcome is inferred.", + ), + (_, "rotationSplit") => Ok( + "Exact keys split only across incomplete rotation fragments cannot establish a complete workflow.", + ), + (_, "malformedRecord") => Ok("Malformed CCM remains a parse coverage state."), + (_, "unknownProfile") => { + Ok("Unknown source version has no selected extraction profile.") + } + (_, "invalidOffset") => Ok( + "Invalid timestamp offset cannot support ordered or high-confidence workflow claims.", + ), + _ => Err(format!( + "{family} observation kind {kind} has no canonical claim" + )), + } +} + +fn effective_state(artifact: &Value) -> Result { + let artifact_id = artifact["artifactId"].as_str().unwrap_or(""); + match required_string(artifact, "captureState", artifact_id)? { + "captured" => { + let complete = artifact["rotation"]["fragmentComplete"] + .as_bool() + .ok_or_else(|| format!("{artifact_id} fragmentComplete is not a bool"))?; + Ok(if complete { "captured" } else { "partial" }.to_owned()) + } + state @ ("absent" | "accessDenied" | "capped" | "skipped" | "unsupported" + | "parseFailed") => Ok(state.to_owned()), + other => Err(format!( + "{artifact_id} has unsupported captureState {other}" + )), + } +} + +fn walk_files(root: &Path) -> Result, String> { + let mut pending = vec![root.to_path_buf()]; + let mut files = Vec::new(); + while let Some(path) = pending.pop() { + if path.is_dir() { + let mut children = std::fs::read_dir(&path) + .map_err(|error| format!("{} is readable: {error}", path.display()))? + .map(|entry| { + entry + .map(|value| value.path()) + .map_err(|error| format!("{} entry is readable: {error}", path.display())) + }) + .collect::, _>>()?; + children.sort(); + pending.extend(children.into_iter().rev()); + } else { + files.push(path); + } + } + Ok(files) +} + +fn normalize_manifest_relative_path(path: &str) -> String { + path.replace('\\', "/") +} + +static TEMP_SCENARIO_SEQUENCE: AtomicUsize = AtomicUsize::new(0); + +struct TemporaryScenario { + root: PathBuf, +} + +impl TemporaryScenario { + fn copy_from(source: &Path, label: &str) -> Self { + let sequence = TEMP_SCENARIO_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "cmtraceopen-sccm-325-{}-{sequence}-{label}", + std::process::id() + )); + std::fs::create_dir(&root) + .unwrap_or_else(|error| panic!("{} can be created: {error}", root.display())); + + for source_file in walk_files(source).expect("source scenario is readable") { + let relative = source_file + .strip_prefix(source) + .expect("scenario file is below source root"); + let destination = root.join(relative); + std::fs::create_dir_all( + destination + .parent() + .expect("scenario copy destination has a parent"), + ) + .expect("scenario copy parent can be created"); + std::fs::copy(&source_file, &destination).unwrap_or_else(|error| { + panic!( + "{} can be copied to {}: {error}", + source_file.display(), + destination.display() + ) + }); + } + + Self { root } + } +} + +impl Drop for TemporaryScenario { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn rewrite_artifact_evidence( + scenario_root: &Path, + manifest: &mut Value, + artifact_index: usize, + contents: &str, +) { + let artifact = &mut manifest["artifacts"][artifact_index]; + let relative_path = artifact["relativePath"] + .as_str() + .expect("rewritten artifact has a relativePath"); + std::fs::write(scenario_root.join(relative_path), contents) + .expect("temporary evidence can be rewritten"); + artifact["bytesCopied"] = json!(contents.len() as u64); +} + +fn rewrite_artifact_by_id( + scenario_root: &Path, + manifest: &mut Value, + artifact_id: &str, + contents: &str, +) { + let artifact_index = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .position(|artifact| artifact["artifactId"] == artifact_id) + .unwrap_or_else(|| panic!("manifest contains artifact {artifact_id}")); + rewrite_artifact_evidence(scenario_root, manifest, artifact_index, contents); +} + +fn copied_contract_with_evidence_replacements( + family: &str, + scenario: &str, + artifact_id: &str, + label: &str, + replacements: &[(&str, &str)], +) -> (TemporaryScenario, Value, Value) { + let (source_root, mut manifest, expected) = load_contract(family, scenario); + let temporary = TemporaryScenario::copy_from(&source_root, label); + let artifact = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .find(|artifact| artifact["artifactId"] == artifact_id) + .unwrap_or_else(|| panic!("manifest contains artifact {artifact_id}")); + let relative_path = artifact["relativePath"] + .as_str() + .expect("rewritten artifact has a relativePath"); + let mut contents = std::fs::read_to_string(temporary.root.join(relative_path)) + .expect("temporary evidence is readable"); + for (from, to) in replacements { + assert!( + contents.contains(from), + "{artifact_id} contains replacement source {from}" + ); + contents = contents.replace(from, to); + } + rewrite_artifact_by_id(&temporary.root, &mut manifest, artifact_id, &contents); + (temporary, manifest, expected) +} + +fn copied_inventory_recovery_with_time_replacements( + label: &str, + replacements: &[(&str, &str)], +) -> (TemporaryScenario, Value, Value) { + let (source_root, mut manifest, expected) = + load_contract("inventory", "recovery-contradictory"); + let temporary = TemporaryScenario::copy_from(&source_root, label); + let relative_path = manifest["artifacts"][0]["relativePath"] + .as_str() + .expect("recovery artifact has a relativePath"); + let mut contents = std::fs::read_to_string(temporary.root.join(relative_path)) + .expect("temporary recovery evidence is readable"); + for (from, to) in replacements { + assert!( + contents.contains(from), + "recovery fixture contains replacement source {from}" + ); + contents = contents.replace(from, to); + } + rewrite_artifact_evidence(&temporary.root, &mut manifest, 0, &contents); + manifest["artifacts"][0]["capturedUtc"] = json!("2026-07-30T11:00:00Z"); + (temporary, manifest, expected) +} + +fn validate_relative_path(relative_path: &str, artifact_id: &str) -> Result<(), String> { + let path = Path::new(relative_path); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!( + "{artifact_id} relativePath escapes the scenario: {relative_path}" + )); + } + if !relative_path.starts_with("evidence/") { + return Err(format!( + "{artifact_id} relativePath is outside the evidence root" + )); + } + Ok(()) +} + +fn validate_source_topology( + family: &str, + artifact_id: &str, + basename: &str, + rotation_kind: &str, + sanitized_path: &str, + fingerprint: &str, + relative_path: Option<&str>, +) -> Result<(), String> { + let source_tail = sanitized_path + .strip_prefix("SYNTHETIC://") + .ok_or_else(|| format!("{artifact_id} source topology is not synthetic"))?; + let root = source_tail + .split('/') + .next() + .ok_or_else(|| format!("{artifact_id} source topology has no synthetic root"))?; + if !root.starts_with("root-") + || !root + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + || sanitized_path != format!("SYNTHETIC://{root}/CCM/Logs/{basename}") + || fingerprint != format!("synthetic-{artifact_id}-{root}") + { + return Err(format!( + "{artifact_id} source topology does not bind path/fingerprint root" + )); + } + if let Some(relative_path) = relative_path { + let expected = format!("evidence/client-{family}/{root}/{rotation_kind}/{basename}"); + if relative_path != expected { + return Err(format!( + "{artifact_id} source topology does not bind relative path {relative_path}" + )); + } + } + Ok(()) +} + +fn validate_next_artifact( + family: &str, + transaction_id: &str, + next_artifact: &Value, +) -> Result<(), String> { + if next_artifact.is_null() { + return Ok(()); + } + let object = next_artifact + .as_object() + .ok_or_else(|| format!("{transaction_id} nextArtifact is not an object"))?; + let actual_fields = object.keys().map(String::as_str).collect::>(); + let expected_fields = ["logicalArtifactId", "reason", "sourceBasename"] + .into_iter() + .collect::>(); + if actual_fields != expected_fields { + return Err(format!( + "{transaction_id} nextArtifact fields are not the bounded contract" + )); + } + if required_string( + next_artifact, + "logicalArtifactId", + &format!("{transaction_id} nextArtifact"), + )? != expected_logical_artifact(family)? + { + return Err(format!( + "{transaction_id} nextArtifact crosses workflow families" + )); + } + let source = required_string( + next_artifact, + "sourceBasename", + &format!("{transaction_id} nextArtifact"), + )?; + if !admitted_sources(family)?.contains(&source) { + return Err(format!( + "{transaction_id} nextArtifact names unadmitted source {source}" + )); + } + let reason = required_string( + next_artifact, + "reason", + &format!("{transaction_id} nextArtifact"), + )?; + let lower = reason.to_ascii_lowercase(); + if reason.is_empty() + || reason.len() > 120 + || reason.contains(['*', '\\', '/']) + || [ + "recursive", + "every log", + "all logs", + "all files", + "volume", + "drive", + ] + .iter() + .any(|needle| lower.contains(needle)) + { + return Err(format!( + "{transaction_id} nextArtifact reason is unbounded or path-bearing" + )); + } + Ok(()) +} + +fn expected_next_artifact( + family: &str, + phase: &str, + classification: &str, +) -> Result, String> { + if !matches!(classification, "confirmedFailure" | "blockedOrDeferred") { + return Ok(None); + } + + let source_basename = match (family, phase) { + ("inventory", "Collect") => "InventoryAgent.log", + ("inventory", "Provider" | "Serialize") => "InventoryProvider.log", + ("inventory", "Queue" | "Report") => "InventoryAgentProvider.log", + ("compliance", "Evaluate") => "CIAgent.log", + ("compliance", "Remediate") => "DCMAgent.log", + ("compliance", "Report") => "DCMReporting.log", + ("metering", "Collect" | "Aggregate" | "Report") => "SWMTRReportGen.log", + _ => { + return Err(format!( + "no bounded nextArtifact contract for {family}/{phase}" + )) + } + }; + + Ok(Some(json!({ + "logicalArtifactId": expected_logical_artifact(family)?, + "sourceBasename": source_basename, + "reason": format!( + "Inspect the same exact {family} key in this admitted {family} source." + ) + }))) +} + +fn additive_artifact(artifact: &Value) -> Result { + let artifact_id = required_string(artifact, "artifactId", "artifact")?; + let rotation = match required_string(&artifact["rotation"], "kind", artifact_id)? { + "current" => SccmRotation::Current, + "lo" => SccmRotation::Unknown(SccmUnknownRotation { + kind: "lo".to_owned(), + value: None, + }), + other => return Err(format!("{artifact_id} has unsupported rotation {other}")), + }; + + Ok(SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: required_string(artifact, "originalBasename", artifact_id)?.to_owned(), + original_path: artifact["sanitizedSourcePath"].as_str().map(str::to_owned), + host: Some("LAB-CLIENT-01".to_owned()), + role: SccmRole::Client, + configmgr_version: artifact["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: artifact["capturedUtc"].as_str().map(str::to_owned), + rotation, + coverage: SccmCoverageState::Captured, + encoding: artifact["encoding"].as_str().map(str::to_owned), + }) +} + +struct CitedEvidenceRecord { + fields: BTreeMap, + source_version: String, + timestamp: SccmTimestamp, +} + +fn strict_ccm_structured_fields( + record: &str, + context: &str, +) -> Result, String> { + const MESSAGE_PREFIX: &str = ""; + if !record.starts_with(MESSAGE_PREFIX) + || record.matches(MESSAGE_PREFIX).count() != 1 + || record.matches(MESSAGE_SUFFIX).count() != 1 + { + return Err(format!("{context} must contain exactly one CCM envelope")); + } + let payload_start = MESSAGE_PREFIX.len(); + let message_end = record[payload_start..] + .find(MESSAGE_SUFFIX) + .ok_or_else(|| format!("{context} must contain exactly one CCM envelope"))?; + let suffix_end = payload_start + message_end + MESSAGE_SUFFIX.len(); + let attributes = &record[suffix_end..]; + if !attributes.starts_with("') { + return Err(format!("{context} must contain exactly one CCM envelope")); + } + + let mut fields = BTreeMap::new(); + for token in record[payload_start..payload_start + message_end].split_ascii_whitespace() { + let Some((name, value)) = token.split_once('=') else { + continue; + }; + if name.is_empty() || value.is_empty() { + return Err(format!("{context} has an empty structured field")); + } + if fields.insert(name.to_owned(), value.to_owned()).is_some() { + return Err(format!("{context} has duplicate structured field {name}")); + } + } + Ok(fields) +} + +fn rotation_lineage_key( + family: &str, + scenario_root: &Path, + artifact: &Value, +) -> Result { + let artifact_id = required_string(artifact, "artifactId", "rotation artifact")?; + let basename = required_string(artifact, "originalBasename", artifact_id)?; + let source_basename = basename.strip_suffix(".lo").unwrap_or(basename); + let source_version = required_string(artifact, "sourceVersion", artifact_id)?; + let sanitized_path = required_string(artifact, "sanitizedSourcePath", artifact_id)?; + let synthetic_root = sanitized_path + .strip_prefix("SYNTHETIC://") + .and_then(|path| path.split('/').next()) + .ok_or_else(|| format!("{artifact_id} rotation source has no synthetic root"))?; + let relative_path = required_string(artifact, "relativePath", artifact_id)?; + let contents = std::fs::read_to_string(scenario_root.join(relative_path)) + .map_err(|error| format!("{artifact_id} rotation evidence is readable: {error}"))?; + let additive_artifact = additive_artifact(artifact)?; + let required_fields = required_key_fields(family)?; + let mut exact_keys = BTreeSet::new(); + + for (index, line) in contents.lines().enumerate() { + let context = format!("{artifact_id}:{}", index + 1); + let normalized = normalize_ccm_artifact(additive_artifact.clone(), line); + if normalized.len() != 1 + || normalized[0].reference.line_start != Some(1) + || normalized[0].reference.line_end != Some(1) + { + return Err(format!("{context} is not one complete CCM record")); + } + let fields = strict_ccm_structured_fields(line, &context)?; + validate_structured_field_vocabulary(&fields, &context)?; + validate_cited_record_semantics(&fields, source_basename, &context)?; + if fields.get("Family").map(String::as_str) != Some(family) { + return Err(format!("{context} Family is not exact")); + } + let mut key_values = Vec::new(); + for field in required_fields { + let value = fields + .get(*field) + .ok_or_else(|| format!("{context} has no exact key field {field}"))?; + if value.is_empty() + || value.contains(['\n', '\r']) + || (field.ends_with("Handle") && !value.starts_with("safe:")) + { + return Err(format!("{context} exact key field {field} is unsafe/empty")); + } + key_values.push(value.as_str()); + } + exact_keys.insert(key_values.join("\0")); + } + if exact_keys.len() != 1 { + return Err(format!( + "{artifact_id} rotation evidence has no single exact key" + )); + } + + Ok(format!( + "{synthetic_root}\0{source_basename}\0{source_version}\0{}", + exact_keys + .into_iter() + .next() + .expect("one exact rotation key was checked") + )) +} + +fn record_field_is(record: &CitedEvidenceRecord, field: &str, value: &str) -> bool { + record + .fields + .get(field) + .is_some_and(|actual| actual == value) +} + +fn evidence_backed_last_successful_phase<'a>( + records: &[CitedEvidenceRecord], + phases: &'a [&'a str], + classification: &str, +) -> Option<&'a str> { + if classification == "symptom" { + return None; + } + + records + .iter() + .filter_map(|record| { + if !record_field_is(record, "Terminal", "true") { + return None; + } + let disposition = record.fields.get("Disposition")?.as_str(); + let completed = match disposition { + "Succeeded" => true, + "Compliant" | "NonCompliant" => { + record_field_is(record, "Family", "compliance") + && record_field_is(record, "Phase", "Evaluate") + && record_field_is(record, "ResultType", "Evaluation") + } + _ => false, + }; + if !completed { + return None; + } + let phase = record.fields.get("Phase")?.as_str(); + phases + .iter() + .position(|candidate| *candidate == phase) + .map(|index| (index, phases[index])) + }) + .max_by_key(|(index, _)| *index) + .map(|(_, phase)| phase) +} + +fn evidence_record_texts( + scenario_root: &Path, + artifacts_by_id: &BTreeMap, + evidence_refs: &[Value], +) -> Result, String> { + let mut records = Vec::new(); + for evidence_ref in evidence_refs { + let artifact_id = required_string(evidence_ref, "artifactId", "evidence reference")?; + let artifact = artifacts_by_id + .get(artifact_id) + .ok_or_else(|| format!("evidence cites unknown artifact {artifact_id}"))?; + if effective_state(artifact)? != "captured" { + return Err(format!( + "evidence cites non-complete artifact {artifact_id}" + )); + } + let relative_path = required_string(artifact, "relativePath", artifact_id)?; + let contents = std::fs::read_to_string(scenario_root.join(relative_path)) + .map_err(|error| format!("{artifact_id} evidence is readable: {error}"))?; + let additive_artifact = additive_artifact(artifact)?; + let captured_utc = required_string(artifact, "capturedUtc", artifact_id)?; + let captured_utc_millis = chrono::DateTime::parse_from_rfc3339(captured_utc) + .map_err(|error| format!("{artifact_id} capturedUtc is invalid: {error}"))? + .timestamp_millis(); + let lines = contents.lines().collect::>(); + let start = evidence_ref["startLine"] + .as_u64() + .ok_or_else(|| format!("{artifact_id} evidence startLine is not an integer"))? + as usize; + let end = evidence_ref["endLine"] + .as_u64() + .ok_or_else(|| format!("{artifact_id} evidence endLine is not an integer"))? + as usize; + if start == 0 || end < start || end > lines.len() { + return Err(format!( + "{artifact_id} evidence range {start}-{end}/{} is invalid", + lines.len() + )); + } + for (offset, line) in lines[start - 1..end].iter().enumerate() { + let normalized = normalize_ccm_artifact(additive_artifact.clone(), line); + if normalized.len() != 1 + || normalized[0].reference.line_start != Some(1) + || normalized[0].reference.line_end != Some(1) + { + return Err(format!( + "{artifact_id}:{} is not one complete CCM record", + start + offset + )); + } + let timestamp = normalized[0].timestamp.clone(); + let Some(utc_millis) = timestamp.utc_millis else { + return Err(format!( + "{artifact_id}:{} lacks normalized additive SCCM timestamp provenance", + start + offset + )); + }; + if timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc { + return Err(format!( + "{artifact_id}:{} lacks normalized additive SCCM timestamp provenance", + start + offset + )); + } + if utc_millis > captured_utc_millis { + return Err(format!( + "{artifact_id}:{} complete cited timestamp is after capturedUtc", + start + offset + )); + } + let source_version = required_string(artifact, "sourceVersion", artifact_id)?; + let record_context = format!("{artifact_id}:{}", start + offset); + let fields = strict_ccm_structured_fields(line, &record_context)?; + validate_structured_field_vocabulary(&fields, &record_context)?; + let basename = required_string(artifact, "originalBasename", artifact_id)?; + let source_basename = basename.strip_suffix(".lo").unwrap_or(basename); + validate_cited_record_semantics(&fields, source_basename, &record_context)?; + records.push(CitedEvidenceRecord { + fields, + source_version: source_version.to_owned(), + timestamp, + }); + } + } + Ok(records) +} + +fn validate_contract( + family: &str, + scenario: &str, + scenario_root: &Path, + manifest: &Value, + expected: &Value, +) -> Result<(), String> { + let logical_artifact = expected_logical_artifact(family)?; + let sources = admitted_sources(family)?; + let phases = admitted_phases(family)?; + let profile = expected_profile(family)?; + + require_exact_object_fields( + manifest, + &[ + "sccmManifestVersion", + "contractState", + "proposalOnly", + "syntheticFixture", + "scenario", + "workflowFamily", + "bundle", + "artifacts", + ], + "manifest", + )?; + require_exact_object_fields( + &manifest["bundle"], + &[ + "bundleId", + "captureHost", + "role", + "siteCode", + "artifactOrder", + "rotationOrder", + ], + "bundle", + )?; + require_exact_object_fields( + expected, + &[ + "contractState", + "scenario", + "workflow", + "extractionProfile", + "transactions", + "sourceLocalObservations", + "coverage", + "findings", + "productionAdmissionError", + "productionOutputSha256", + "prohibitedClaims", + ], + "expected", + )?; + require_exact_object_fields( + &expected["extractionProfile"], + &["id", "selectionState", "versionPrefix"], + "extractionProfile", + )?; + + if manifest["sccmManifestVersion"] != 1 + || manifest["contractState"] != "proposedPending318And319" + || manifest["proposalOnly"] != true + || manifest["syntheticFixture"] != true + || manifest["scenario"] != scenario + || manifest["workflowFamily"] != family + { + return Err("manifest identity/version/proposal contract is invalid".to_owned()); + } + if manifest["bundle"]["role"] != "client" + || manifest["bundle"]["captureHost"] != "LAB-CLIENT-01" + || manifest["bundle"]["siteCode"] != "LAB" + { + return Err("bundle identity is not the sanitized client fixture identity".to_owned()); + } + let bundle_id = required_string(&manifest["bundle"], "bundleId", "bundle")?; + let expected_bundle_id = format!("sccm-325-{family}-{scenario}"); + if bundle_id != expected_bundle_id { + return Err("bundleId is not exact issue/family/scenario identity".to_owned()); + } + for (field, expected_value) in [ + ( + "artifactOrder", + "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + ), + ( + "rotationOrder", + "current,lo,numeric-ascending,timestamp-ascending", + ), + ] { + if required_string(&manifest["bundle"], field, "bundle")? != expected_value { + return Err(format!("bundle {field} is not the deterministic contract")); + } + } + + let artifacts = manifest["artifacts"] + .as_array() + .ok_or_else(|| "manifest artifacts are not an array".to_owned())?; + if artifacts.is_empty() { + return Err("scenario has no artifacts".to_owned()); + } + require_canonical_string_field_order(artifacts, "artifactId", "manifest artifact")?; + let mut artifacts_by_id = BTreeMap::new(); + let mut relative_paths = BTreeSet::new(); + let mut physical_source_identities = BTreeSet::new(); + let mut path_fingerprints = BTreeSet::new(); + let mut referenced_files = BTreeSet::new(); + let mut expected_coverage = BTreeMap::new(); + let mut unknown_version_artifacts = BTreeSet::new(); + let mut invalid_offset_artifacts = BTreeSet::new(); + let artifact_id_prefix = format!("{family}-{scenario}-"); + + for artifact in artifacts { + let artifact_id = required_string(artifact, "artifactId", "artifact")?; + validate_canonical_id(artifact_id, "artifactId", &artifact_id_prefix)?; + if artifacts_by_id + .insert(artifact_id.to_owned(), artifact) + .is_some() + { + return Err(format!("duplicate artifactId {artifact_id}")); + } + if artifact["role"] != "client" || artifact["kind"] != "ccmLog" { + return Err(format!("{artifact_id} is not a client CCM artifact")); + } + require_exact_object_fields( + &artifact["designOnlyCatalog"], + &["entryId", "groupMemberships"], + &format!("{artifact_id} designOnlyCatalog"), + )?; + require_exact_object_fields( + &artifact["rotation"], + &["kind", "fragmentComplete"], + &format!("{artifact_id} rotation"), + )?; + let captured_utc = required_string(artifact, "capturedUtc", artifact_id)?; + let parsed_captured_utc = chrono::DateTime::parse_from_rfc3339(captured_utc) + .map_err(|error| format!("{artifact_id} capturedUtc is invalid: {error}"))?; + let canonical_captured_utc = + parsed_captured_utc.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + if parsed_captured_utc.offset().local_minus_utc() != 0 + || canonical_captured_utc != captured_utc + { + return Err(format!( + "{artifact_id} capturedUtc is not canonical UTC provenance" + )); + } + if artifact["designOnlyCatalog"]["entryId"] != logical_artifact + || artifact["designOnlyCatalog"]["groupMemberships"] != json!([logical_artifact]) + { + return Err(format!("{artifact_id} crosses logical workflow families")); + } + + let basename = required_string(artifact, "originalBasename", artifact_id)?; + let admitted_basename = basename.strip_suffix(".lo").unwrap_or(basename); + if !sources.contains(&admitted_basename) { + return Err(format!( + "{artifact_id} uses unadmitted {family} source {basename}" + )); + } + let rotation_kind = required_string(&artifact["rotation"], "kind", artifact_id)?; + match rotation_kind { + "current" if basename.ends_with(".lo") => { + return Err(format!("{artifact_id} current rotation has .lo basename")); + } + "lo" if !basename.ends_with(".lo") => { + return Err(format!("{artifact_id} lo rotation lacks .lo basename")); + } + "current" | "lo" => {} + other => return Err(format!("{artifact_id} has unsupported rotation {other}")), + } + + let state = effective_state(artifact)?; + if expected_coverage + .insert(artifact_id.to_owned(), state.clone()) + .is_some() + { + return Err(format!("duplicate coverage identity {artifact_id}")); + } + let capture_state = required_string(artifact, "captureState", artifact_id)?; + let physical = matches!(capture_state, "captured" | "capped" | "parseFailed"); + let mut exact_artifact_fields = vec![ + "artifactId", + "bytesCopied", + "captureState", + "capturedUtc", + "designOnlyCatalog", + "kind", + "originalBasename", + "pathFingerprint", + "relativePath", + "role", + "rotation", + "sanitizedSourcePath", + "sourceVersion", + ]; + if physical { + exact_artifact_fields.extend(["collectionLimit", "encoding"]); + } + if capture_state == "capped" { + exact_artifact_fields.push("truncated"); + } + if capture_state == "captured" + && (artifact["collectionLimit"]["limitApplied"] != false + || artifact.get("truncated").is_some()) + { + return Err(format!( + "{artifact_id} captured state provenance claims a cap/truncation" + )); + } + if !physical + && (artifact.get("encoding").is_some() + || artifact.get("collectionLimit").is_some() + || artifact.get("truncated").is_some()) + { + return Err(format!( + "{artifact_id} nonphysical state provenance invents retained-byte fields" + )); + } + require_exact_object_fields(artifact, &exact_artifact_fields, "artifact")?; + let relative_path = match (&artifact["relativePath"], physical) { + (Value::String(relative_path), true) => Some(relative_path.as_str()), + (Value::Null, false) => None, + _ => { + return Err(format!( + "{artifact_id} relativePath type does not match capture state {capture_state}" + )) + } + }; + let source_version = match &artifact["sourceVersion"] { + Value::String(source_version) => Some(source_version.as_str()), + Value::Null => None, + _ => { + return Err(format!( + "{artifact_id} sourceVersion is neither a string nor null" + )) + } + }; + if let Some(source_version) = source_version { + validate_source_version(source_version, artifact_id)?; + if !source_version.starts_with("5.00.TEST.") { + unknown_version_artifacts.insert(artifact_id.to_owned()); + } + } + + if let Some(relative_path) = relative_path { + validate_relative_path(relative_path, artifact_id)?; + if !relative_path.contains(&format!("/{rotation_kind}/")) + || !relative_path.ends_with(basename) + { + return Err(format!( + "{artifact_id} path is incoherent with rotation/basename" + )); + } + if !relative_paths.insert(relative_path.to_owned()) { + return Err(format!("duplicate physical evidence path {relative_path}")); + } + let full_path = scenario_root.join(relative_path); + let bytes = std::fs::read(&full_path) + .map_err(|error| format!("{} is readable: {error}", full_path.display()))?; + let declared_bytes = artifact["bytesCopied"] + .as_u64() + .ok_or_else(|| format!("{artifact_id} bytesCopied is not an integer"))?; + if declared_bytes != bytes.len() as u64 { + return Err(format!( + "{artifact_id} bytesCopied {declared_bytes} != {}", + bytes.len() + )); + } + if artifact["encoding"] != "utf-8" || std::str::from_utf8(&bytes).is_err() { + return Err(format!("{artifact_id} is not declared and encoded UTF-8")); + } + require_exact_object_fields( + &artifact["collectionLimit"], + &["byteLimit", "limitApplied"], + &format!("{artifact_id} collectionLimit"), + )?; + let byte_limit = artifact["collectionLimit"]["byteLimit"] + .as_u64() + .ok_or_else(|| format!("{artifact_id} byteLimit is not an integer"))?; + if capture_state == "capped" { + if artifact["collectionLimit"]["limitApplied"] != true + || artifact["truncated"] != true + || artifact["rotation"]["fragmentComplete"] != false + || byte_limit != declared_bytes + { + return Err(format!( + "{artifact_id} capped state is not an inclusive exact prefix" + )); + } + } else if artifact["collectionLimit"]["limitApplied"] != false + || artifact.get("truncated").is_some() + || declared_bytes > byte_limit + { + return Err(format!( + "{artifact_id} {capture_state} state provenance is not uncapped" + )); + } + if capture_state == "parseFailed" && artifact["rotation"]["fragmentComplete"] != false { + return Err(format!( + "{artifact_id} parseFailed artifact is marked complete" + )); + } + let sanitized_path = required_string(artifact, "sanitizedSourcePath", artifact_id)?; + let fingerprint = required_string(artifact, "pathFingerprint", artifact_id)?; + if fingerprint.is_empty() || !path_fingerprints.insert(fingerprint.to_owned()) { + return Err(format!( + "{artifact_id} has blank or aliased pathFingerprint" + )); + } + validate_source_topology( + family, + artifact_id, + basename, + rotation_kind, + sanitized_path, + fingerprint, + Some(relative_path), + )?; + let capture_host = required_string(&manifest["bundle"], "captureHost", "bundle")?; + if !physical_source_identities.insert(( + capture_host.to_owned(), + sanitized_path.to_owned(), + rotation_kind.to_owned(), + )) { + return Err(format!( + "{artifact_id} has duplicate physical source identity" + )); + } + referenced_files.insert(relative_path.to_owned()); + + if source_version.is_none() { + return Err(format!( + "{artifact_id} physical source has no sourceVersion" + )); + } + + if capture_state == "captured" && artifact["rotation"]["fragmentComplete"] == true { + let contents = std::str::from_utf8(&bytes).expect("validated UTF-8"); + let evidence = normalize_ccm_artifact(additive_artifact(artifact)?, contents); + if evidence.iter().any(|record| { + record.timestamp.ordering_state == SccmTimeOrderingState::OffsetInvalid + }) { + invalid_offset_artifacts.insert(artifact_id.to_owned()); + } + } + } else { + if artifact["bytesCopied"] != 0 || artifact["rotation"]["fragmentComplete"] != false { + return Err(format!( + "{artifact_id} nonphysical state has bytes or complete fragment" + )); + } + if capture_state == "absent" + && (!artifact["sanitizedSourcePath"].is_null() + || !artifact["pathFingerprint"].is_null() + || !artifact["sourceVersion"].is_null()) + { + return Err(format!( + "{artifact_id} absent source invents path/version identity" + )); + } + if capture_state != "absent" { + let sanitized_path = required_string(artifact, "sanitizedSourcePath", artifact_id)?; + let fingerprint = required_string(artifact, "pathFingerprint", artifact_id)?; + if fingerprint.is_empty() || !path_fingerprints.insert(fingerprint.to_owned()) { + return Err(format!( + "{artifact_id} has blank or aliased attempted-path fingerprint" + )); + } + validate_source_topology( + family, + artifact_id, + basename, + rotation_kind, + sanitized_path, + fingerprint, + None, + )?; + let capture_host = required_string(&manifest["bundle"], "captureHost", "bundle")?; + if !physical_source_identities.insert(( + capture_host.to_owned(), + sanitized_path.to_owned(), + rotation_kind.to_owned(), + )) { + return Err(format!( + "{artifact_id} has duplicate physical source identity" + )); + } + } + } + } + + let actual_files = walk_files(&scenario_root.join("evidence"))? + .into_iter() + .map(|path| { + let relative_path = path + .strip_prefix(scenario_root) + .expect("walk root is below scenario") + .to_string_lossy(); + normalize_manifest_relative_path(&relative_path) + }) + .collect::>(); + if actual_files != referenced_files { + return Err(format!( + "manifest evidence projection differs: actual {actual_files:?}, referenced {referenced_files:?}" + )); + } + + if expected["contractState"] != "proposedPending318And319" + || expected["scenario"] != scenario + || expected["workflow"] != family + { + return Err("expected contract identity is invalid".to_owned()); + } + if scenario == "malformed-unknown-profile-invalid-offset" { + if !expected["productionOutputSha256"].is_null() + || expected["productionAdmissionError"] + != "fixture-update-numbered-03: client intake artifact ConfigMgr version is unsafe or too long" + { + return Err("rejected production outcome is not the exact committed oracle".to_owned()); + } + } else { + let digest = required_string(expected, "productionOutputSha256", "expected")?; + if digest.len() != 64 + || !digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || !expected["productionAdmissionError"].is_null() + { + return Err("admitted production outcome is not a lowercase SHA-256 oracle".to_owned()); + } + } + if expected["extractionProfile"]["id"] != profile + || expected["extractionProfile"]["versionPrefix"] != "5.00.TEST." + { + return Err("expected extraction profile is not family/version bound".to_owned()); + } + let profile_selection = required_string( + &expected["extractionProfile"], + "selectionState", + "extractionProfile", + )?; + let required_selection = if unknown_version_artifacts.is_empty() { + "selected" + } else { + "mixedKnownAndUnknown" + }; + if profile_selection != required_selection { + return Err(format!( + "profile selection {profile_selection} != {required_selection}" + )); + } + + let coverage = expected["coverage"] + .as_array() + .ok_or_else(|| "expected coverage is not an array".to_owned())?; + require_canonical_string_field_order(coverage, "artifactId", "coverage")?; + let mut declared_coverage = BTreeMap::new(); + for row in coverage { + require_exact_object_fields( + row, + &["artifactId", "logicalArtifactId", "state"], + "coverage row", + )?; + let artifact_id = required_string(row, "artifactId", "coverage row")?; + if row["logicalArtifactId"] != logical_artifact { + return Err(format!("{artifact_id} coverage crosses workflow families")); + } + let state = required_string(row, "state", artifact_id)?; + if declared_coverage + .insert(artifact_id.to_owned(), state.to_owned()) + .is_some() + { + return Err(format!("duplicate coverage row {artifact_id}")); + } + } + if declared_coverage != expected_coverage { + return Err(format!( + "coverage is not an exact manifest projection: {declared_coverage:?} != {expected_coverage:?}" + )); + } + + if expected["findings"] + .as_array() + .is_none_or(|findings| !findings.is_empty()) + { + return Err("preparation corpus must not ship production findings".to_owned()); + } + let prohibited_claims = expected["prohibitedClaims"] + .as_array() + .ok_or_else(|| "prohibitedClaims is not an array".to_owned())?; + if prohibited_claims.len() != 4 + || expected["prohibitedClaims"] + != json!([ + "missing or unreadable evidence proves workflow success or failure", + "same-minute records are causally related without one exact validated key tuple", + "client evidence alone proves a server-side cause", + "this preparation corpus is live Windows acceptance" + ]) + { + return Err("prohibitedClaims are not the exact non-claim contract".to_owned()); + } + + let observations = expected["sourceLocalObservations"] + .as_array() + .ok_or_else(|| "sourceLocalObservations is not an array".to_owned())?; + require_canonical_string_field_order(observations, "observationId", "observation")?; + let mut observation_ids = BTreeSet::new(); + let mut observation_memberships = BTreeSet::new(); + let mut observed_artifact_ids = BTreeSet::new(); + let mut unknown_profile_observations = BTreeSet::new(); + let mut invalid_offset_observations = BTreeSet::new(); + for observation in observations { + require_exact_object_fields( + observation, + &[ + "observationId", + "kind", + "artifactIds", + "confidenceCeiling", + "correlationEligible", + "claim", + ], + "observation", + )?; + let observation_id = required_string(observation, "observationId", "observation")?; + validate_canonical_id(observation_id, "observationId", &format!("{family}-"))?; + if !observation_ids.insert(observation_id.to_owned()) { + return Err(format!("duplicate observationId {observation_id}")); + } + if observation["confidenceCeiling"] != "low" || observation["correlationEligible"] != false + { + return Err(format!( + "{observation_id} exceeds the source-local confidence ceiling" + )); + } + let kind = required_string(observation, "kind", observation_id)?; + if !matches!( + kind, + "coverageGap" + | "rotationSplit" + | "malformedRecord" + | "unknownProfile" + | "invalidOffset" + ) { + return Err(format!("{observation_id} has unsupported kind {kind}")); + } + let claim = required_string(observation, "claim", observation_id)?; + if claim != expected_observation_claim(family, kind)? { + return Err(format!("{observation_id} claim is not canonical")); + } + let artifact_ids = observation["artifactIds"] + .as_array() + .ok_or_else(|| format!("{observation_id} artifactIds is not an array"))?; + if artifact_ids.is_empty() { + return Err(format!( + "{observation_id} has no bounded artifact references" + )); + } + let mut previous_artifact_id = None; + let mut observed_states = Vec::new(); + let mut observed_rotations = BTreeSet::new(); + let mut observed_rotation_artifacts = Vec::new(); + for artifact_id in artifact_ids { + let artifact_id = artifact_id + .as_str() + .ok_or_else(|| format!("{observation_id} artifact ID is not a string"))?; + if previous_artifact_id.is_some_and(|value| value >= artifact_id) { + return Err(format!( + "{observation_id} observation artifact order is not canonical" + )); + } + previous_artifact_id = Some(artifact_id); + if !artifacts_by_id.contains_key(artifact_id) { + return Err(format!( + "{observation_id} references unknown artifact {artifact_id}" + )); + } + let artifact = artifacts_by_id + .get(artifact_id) + .expect("artifact existence checked"); + observed_states.push(effective_state(artifact)?); + observed_rotations.insert(required_string(&artifact["rotation"], "kind", artifact_id)?); + if kind == "rotationSplit" { + observed_rotation_artifacts.push(*artifact); + } + observed_artifact_ids.insert(artifact_id.to_owned()); + if !observation_memberships.insert((kind.to_owned(), artifact_id.to_owned())) { + let kind_label = match kind { + "coverageGap" => "coverage-gap", + "rotationSplit" => "rotation-split", + "malformedRecord" => "malformed-record", + "unknownProfile" => "unknown-profile", + "invalidOffset" => "invalid-offset", + _ => "unsupported", + }; + return Err(format!( + "{observation_id} is a duplicate source-local observation; duplicate {kind_label} observation for {artifact_id}" + )); + } + if kind == "unknownProfile" { + unknown_profile_observations.insert(artifact_id.to_owned()); + } + if kind == "invalidOffset" { + invalid_offset_observations.insert(artifact_id.to_owned()); + } + } + if kind == "rotationSplit" { + if observed_states.len() < 2 + || observed_states.iter().any(|state| state != "partial") + || observed_rotations != ["current", "lo"].into_iter().collect::>() + { + return Err(format!( + "{observation_id} rotationSplit is incompatible with cited artifact coverage/provenance" + )); + } + let observed_rotation_lineages = observed_rotation_artifacts + .into_iter() + .map(|artifact| { + rotation_lineage_key(family, scenario_root, artifact).map_err(|error| { + format!("{observation_id} rotationSplit lineage/key is invalid: {error}") + }) + }) + .collect::, _>>()?; + if observed_rotation_lineages.len() != 1 { + return Err(format!( + "{observation_id} rotationSplit lineage/key is not common" + )); + } + } + let incompatible = match kind { + "coverageGap" => observed_states.iter().any(|state| state == "captured"), + "rotationSplit" => false, + "malformedRecord" => observed_states.iter().any(|state| state != "parseFailed"), + "unknownProfile" => artifact_ids.iter().any(|artifact_id| { + !artifact_id + .as_str() + .is_some_and(|value| unknown_version_artifacts.contains(value)) + }), + "invalidOffset" => artifact_ids.iter().any(|artifact_id| { + !artifact_id + .as_str() + .is_some_and(|value| invalid_offset_artifacts.contains(value)) + }), + _ => true, + }; + if incompatible { + return Err(format!( + "{observation_id} {kind} is incompatible with cited artifact coverage/provenance" + )); + } + } + for (artifact_id, state) in &expected_coverage { + if state != "captured" && !observed_artifact_ids.contains(artifact_id) { + return Err(format!( + "{artifact_id} {state} coverage is not surfaced source-locally" + )); + } + } + if unknown_profile_observations != unknown_version_artifacts { + return Err(format!( + "unknown-profile observations {unknown_profile_observations:?} != artifacts {unknown_version_artifacts:?}" + )); + } + if invalid_offset_observations != invalid_offset_artifacts { + return Err(format!( + "invalid-offset observations {invalid_offset_observations:?} != artifacts {invalid_offset_artifacts:?}" + )); + } + + let transactions = expected["transactions"] + .as_array() + .ok_or_else(|| "transactions are not an array".to_owned())?; + let mut transaction_ids = BTreeSet::new(); + let mut logical_transaction_identities = BTreeSet::new(); + let mut cited_evidence_ranges: BTreeMap> = BTreeMap::new(); + let mut previous_transaction_order: Option<(String, u64, u64, String)> = None; + let mut scenario_semantics = Vec::new(); + for transaction in transactions { + require_exact_object_fields( + transaction, + &[ + "transactionId", + "workflow", + "key", + "phase", + "state", + "classification", + "confidence", + "lastSuccessfulPhase", + "evidence", + "coverageGapArtifactIds", + "nextArtifact", + ], + "transaction", + )?; + let transaction_id = required_string(transaction, "transactionId", "transaction")?; + validate_canonical_id(transaction_id, "transactionId", &format!("{family}-"))?; + if !transaction_ids.insert(transaction_id.to_owned()) { + return Err(format!("duplicate transactionId {transaction_id}")); + } + if transaction["workflow"] != family { + return Err(format!("{transaction_id} crosses workflow families")); + } + + let key = transaction["key"] + .as_object() + .ok_or_else(|| format!("{transaction_id} key is not an object"))?; + let required_fields = required_key_fields(family)?; + let mut expected_key_fields = required_fields.iter().copied().collect::>(); + expected_key_fields.extend(["keyProfileKind", "extractionProfileId", "confidence"]); + let actual_key_fields = key.keys().map(String::as_str).collect::>(); + if actual_key_fields != expected_key_fields { + return Err(format!( + "{transaction_id} key fields {actual_key_fields:?} are not exact {family} fields {expected_key_fields:?}" + )); + } + if key["keyProfileKind"] != format!("{family}Exact") + || key["extractionProfileId"] != profile + || key["confidence"] != "exact" + { + return Err(format!( + "{transaction_id} key profile/confidence is not exact and versioned" + )); + } + for field in required_fields { + let value = key[*field] + .as_str() + .ok_or_else(|| format!("{transaction_id} key {field} is not a string"))?; + if value.is_empty() + || value.contains(['\n', '\r']) + || (field.ends_with("Handle") && !value.starts_with("safe:")) + { + return Err(format!("{transaction_id} key {field} is unsafe/empty")); + } + } + let logical_identity = format!( + "{family}\0{profile}\0{}", + required_fields + .iter() + .map(|field| key[*field].as_str().expect("validated key string")) + .collect::>() + .join("\0") + ); + if !logical_transaction_identities.insert(logical_identity) { + return Err(format!( + "{transaction_id} has duplicate logical transaction identity" + )); + } + + let phase = required_string(transaction, "phase", transaction_id)?; + if !phases.contains(&phase) { + return Err(format!( + "{transaction_id} has invalid {family} phase {phase}" + )); + } + let phase_index = phases + .iter() + .position(|candidate| *candidate == phase) + .expect("admitted phase checked above"); + let last_successful_phase = + if let Some(last_phase) = transaction["lastSuccessfulPhase"].as_str() { + if !phases.contains(&last_phase) { + return Err(format!( + "{transaction_id} lastSuccessfulPhase {last_phase} is invalid" + )); + } + Some(last_phase) + } else if !transaction["lastSuccessfulPhase"].is_null() { + return Err(format!( + "{transaction_id} lastSuccessfulPhase is neither string nor null" + )); + } else { + None + }; + if last_successful_phase.is_some_and(|last_phase| { + phases + .iter() + .position(|candidate| *candidate == last_phase) + .expect("admitted last-success phase checked above") + > phase_index + }) { + return Err(format!( + "{transaction_id} lastSuccessfulPhase follows the transaction phase" + )); + } + let evidence_refs = transaction["evidence"] + .as_array() + .ok_or_else(|| format!("{transaction_id} evidence is not an array"))?; + if evidence_refs.is_empty() { + return Err(format!("{transaction_id} has no cited evidence")); + } + let mut evidence_order = Vec::new(); + for evidence_ref in evidence_refs { + require_exact_object_fields( + evidence_ref, + &["artifactId", "startLine", "endLine"], + &format!("{transaction_id} evidence reference"), + )?; + let artifact_id = + required_string(evidence_ref, "artifactId", "evidence reference")?.to_owned(); + let start = evidence_ref["startLine"] + .as_u64() + .ok_or_else(|| format!("{transaction_id} evidence startLine is not an integer"))?; + let end = evidence_ref["endLine"] + .as_u64() + .ok_or_else(|| format!("{transaction_id} evidence endLine is not an integer"))?; + evidence_order.push((artifact_id, start, end)); + } + if evidence_order.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(format!("{transaction_id} evidence order is not canonical")); + } + for (artifact_id, start, end) in &evidence_order { + if start == &0 || end < start { + return Err(format!( + "{transaction_id} evidence range {start}-{end} is invalid" + )); + } + let prior_ranges = cited_evidence_ranges + .entry(artifact_id.clone()) + .or_default(); + if prior_ranges + .iter() + .any(|(prior_start, prior_end)| start <= prior_end && prior_start <= end) + { + return Err(format!( + "{transaction_id} has overlapping evidence line identity {artifact_id}:{start}-{end}" + )); + } + prior_ranges.push((*start, *end)); + } + let first_evidence = evidence_order + .first() + .expect("nonempty evidence checked above"); + let transaction_order = ( + first_evidence.0.clone(), + first_evidence.1, + first_evidence.2, + transaction_id.to_owned(), + ); + if previous_transaction_order + .as_ref() + .is_some_and(|previous| previous >= &transaction_order) + { + return Err(format!( + "{transaction_id} transaction order is not canonical" + )); + } + previous_transaction_order = Some(transaction_order); + let records = evidence_record_texts(scenario_root, &artifacts_by_id, evidence_refs)?; + for record in &records { + if !record_field_is(record, "Family", family) { + return Err(format!( + "{transaction_id} Family is not source-record-local and exact" + )); + } + for field in required_fields { + let value = key[*field].as_str().expect("validated key string"); + if !record_field_is(record, field, value) { + return Err(format!( + "{transaction_id} {field} is not co-located in every cited CCM record" + )); + } + } + } + if !records + .iter() + .any(|record| record_field_is(record, "Phase", phase)) + { + return Err(format!( + "{transaction_id} phase {phase} is not bound to cited evidence" + )); + } + + let confidence = required_string(transaction, "confidence", transaction_id)?; + if records + .iter() + .any(|record| !record.source_version.starts_with("5.00.TEST.")) + { + return Err(format!( + "{transaction_id} exact-key transaction lacks selected profile provenance" + )); + } + let state = required_string(transaction, "state", transaction_id)?; + let classification = required_string(transaction, "classification", transaction_id)?; + let has_phase_record = |disposition: &str, terminal: bool| { + records.iter().any(|record| { + record_field_is(record, "Phase", phase) + && record_field_is(record, "Disposition", disposition) + && record_field_is(record, "Terminal", if terminal { "true" } else { "false" }) + }) + }; + let terminal_dispositions = records + .iter() + .filter(|record| { + record_field_is(record, "Phase", phase) + && record_field_is(record, "Terminal", "true") + }) + .filter_map(|record| record.fields.get("Disposition").cloned()) + .collect::>(); + if confidence == "high" && terminal_dispositions.len() > 1 { + return Err(format!( + "{transaction_id} high confidence cites opposing terminal evidence" + )); + } + scenario_semantics.push(format!("{phase}|{state}|{classification}")); + match classification { + "confirmedFailure" => { + if state != "failed" || confidence != "high" || !has_phase_record("Failed", true) { + return Err(format!( + "{transaction_id} confirmed failure lacks a terminal cited failure" + )); + } + } + "success" => { + if !matches!(state, "succeeded" | "remediated") + || confidence != "high" + || phase != "Report" + || !has_phase_record("Succeeded", true) + { + return Err(format!( + "{transaction_id} success lacks a terminal cited report" + )); + } + } + "evaluationResult" => { + let disposition = match state { + "evaluatedNonCompliant" => "NonCompliant", + "evaluatedCompliant" => "Compliant", + _ => { + return Err(format!( + "{transaction_id} evaluation result is misclassified as {state}" + )); + } + }; + if family != "compliance" + || phase != "Evaluate" + || confidence != "high" + || !records.iter().any(|record| { + record_field_is(record, "Phase", phase) + && record_field_is(record, "Disposition", disposition) + && record_field_is(record, "Terminal", "true") + && record_field_is(record, "ResultType", "Evaluation") + }) + { + return Err(format!( + "{transaction_id} compliance evaluation result is not source-record-local" + )); + } + } + "recovery" => { + if state != "recovered" + || confidence != "medium" + || !has_phase_record("Failed", true) + || !has_phase_record("Succeeded", true) + { + return Err(format!( + "{transaction_id} recovery lacks both terminal failure and success" + )); + } + let latest_failure = records + .iter() + .filter(|record| { + record_field_is(record, "Phase", phase) + && record_field_is(record, "Disposition", "Failed") + && record_field_is(record, "Terminal", "true") + }) + .map(|record| { + record + .timestamp + .utc_millis + .expect("normalized timestamp checked during evidence loading") + }) + .max() + .expect("terminal failure checked above"); + let earliest_success = records + .iter() + .filter(|record| { + record_field_is(record, "Phase", phase) + && record_field_is(record, "Disposition", "Succeeded") + && record_field_is(record, "Terminal", "true") + }) + .map(|record| { + record + .timestamp + .utc_millis + .expect("normalized timestamp checked during evidence loading") + }) + .min() + .expect("terminal success checked above"); + if earliest_success <= latest_failure { + return Err(format!( + "{transaction_id} recovery is not strictly ordered after every cited failure" + )); + } + } + "symptom" => { + let has_opposing_terminal_records = (has_phase_record("Failed", true) + && has_phase_record("Succeeded", true)) + || (has_phase_record("NonCompliant", true) + && has_phase_record("Compliant", true)); + if state != "contradictory" + || confidence != "low" + || records.len() < 2 + || !has_opposing_terminal_records + { + return Err(format!( + "{transaction_id} contradiction is not conservatively classified" + )); + } + } + "blockedOrDeferred" => { + if state != "blockedOrDeferred" + || confidence != "low" + || !(has_phase_record("Pending", false) || has_phase_record("Deferred", false)) + { + return Err(format!( + "{transaction_id} blocked/deferred state lacks an explicit non-terminal pending or deferred record" + )); + } + } + other => { + return Err(format!( + "{transaction_id} has unsupported preparation classification {other}" + )); + } + } + let evidence_backed_last_success = + evidence_backed_last_successful_phase(&records, phases, classification); + if last_successful_phase != evidence_backed_last_success { + return Err(format!( + "{transaction_id} lastSuccessfulPhase {last_successful_phase:?} is not evidence-backed as {evidence_backed_last_success:?}" + )); + } + + let coverage_gap_ids = transaction["coverageGapArtifactIds"] + .as_array() + .ok_or_else(|| format!("{transaction_id} coverageGapArtifactIds is not an array"))?; + let mut previous_gap_id = None; + for artifact_id in coverage_gap_ids { + let artifact_id = artifact_id + .as_str() + .ok_or_else(|| format!("{transaction_id} coverage gap ID is not a string"))?; + if previous_gap_id.is_some_and(|value| value >= artifact_id) { + return Err(format!( + "{transaction_id} coverage gap order is not canonical" + )); + } + previous_gap_id = Some(artifact_id); + let artifact = artifacts_by_id + .get(artifact_id) + .ok_or_else(|| format!("{transaction_id} coverage gap cites {artifact_id}"))?; + if effective_state(artifact)? == "captured" { + return Err(format!( + "{transaction_id} coverage gap cites complete artifact {artifact_id}" + )); + } + } + validate_next_artifact(family, transaction_id, &transaction["nextArtifact"])?; + let required_next_artifact = expected_next_artifact(family, phase, classification)?; + match ( + required_next_artifact, + transaction["nextArtifact"].is_null(), + ) { + (Some(_), true) => { + return Err(format!("{transaction_id} erased required nextArtifact")) + } + (None, false) => return Err(format!("{transaction_id} has spurious nextArtifact")), + (Some(required), false) if transaction["nextArtifact"] != required => { + return Err(format!( + "{transaction_id} nextArtifact differs from required bounded request" + )) + } + _ => {} + } + } + if scenario_semantics != required_scenario_semantics(family, scenario)? { + return Err(format!( + "{family}/{scenario} required scenario semantics changed: {scenario_semantics:?}" + )); + } + + Ok(()) +} + +fn assert_rejected( + label: &str, + family: &str, + scenario: &str, + scenario_root: &Path, + manifest: &Value, + expected: &Value, +) { + assert!( + validate_contract(family, scenario, scenario_root, manifest, expected).is_err(), + "dynamic adversarial mutation `{label}` was accepted" + ); +} + +fn assert_rejected_with( + label: &str, + family: &str, + scenario: &str, + scenario_root: &Path, + manifest: &Value, + expected: &Value, + required_error: &str, +) { + let error = match validate_contract(family, scenario, scenario_root, manifest, expected) { + Err(error) => error, + Ok(()) => panic!("dynamic adversarial mutation `{label}` was accepted"), + }; + assert!( + error.contains(required_error), + "`{label}` was rejected for the wrong reason: {error}" + ); +} + +fn collect_contract_rejection( + failures: &mut Vec, + label: &str, + result: Result<(), String>, + required_error: &str, +) { + match result { + Err(error) if error.contains(required_error) => {} + Err(error) => failures.push(format!("{label}: wrong rejection: {error}")), + Ok(()) => failures.push(format!("{label}: unsafe mutation was accepted")), + } +} + +#[test] +fn corpus_matrix_keeps_inventory_compliance_and_metering_separate() { + assert_eq!( + directory_names(&corpus_root()), + ["compliance", "inventory", "metering"], + "the preparation corpus has exactly three independent workflow families" + ); + + for (family, expected) in family_scenarios() { + assert_eq!( + directory_names(&corpus_root().join(family)), + expected, + "{family} scenario matrix changed without an explicit contract update" + ); + } +} + +#[test] +fn corpus_inventory_is_deterministic_and_documented() { + assert_eq!( + corpus_inventory(), + CorpusInventory { + scenarios: 21, + artifacts: 55, + evidence_files: 43, + evidence_bytes: 17_136, + capture_states: BTreeMap::from([ + ("absent".to_owned(), 3), + ("accessDenied".to_owned(), 3), + ("capped".to_owned(), 3), + ("captured".to_owned(), 36), + ("parseFailed".to_owned(), 4), + ("skipped".to_owned(), 3), + ("unsupported".to_owned(), 3), + ]), + digest: DOCUMENTED_CORPUS_DIGEST.to_owned(), + }, + "fixture inventory changed; review provenance and update the documented digest" + ); +} + +#[test] +fn review_blocker_applied_caps_are_inclusive_exact_prefixes() { + for family in ["inventory", "compliance", "metering"] { + let (scenario_root, manifest, _) = load_contract(family, "coverage-states"); + let capped = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .filter(|artifact| artifact["captureState"] == "capped") + .collect::>(); + assert_eq!(capped.len(), 1, "{family} has exactly one capped fixture"); + + let artifact = capped[0]; + let artifact_id = artifact["artifactId"] + .as_str() + .expect("capped artifactId is a string"); + let relative_path = artifact["relativePath"] + .as_str() + .expect("capped artifact has retained evidence"); + let file_size = std::fs::metadata(scenario_root.join(relative_path)) + .expect("capped evidence metadata is readable") + .len(); + let bytes_copied = artifact["bytesCopied"] + .as_u64() + .expect("capped bytesCopied is an integer"); + let byte_limit = artifact["collectionLimit"]["byteLimit"] + .as_u64() + .expect("capped byteLimit is an integer"); + + assert_eq!( + ( + artifact["collectionLimit"]["limitApplied"].as_bool(), + artifact["truncated"].as_bool() + ), + (Some(true), Some(true)), + "{artifact_id} records an applied truncating cap" + ); + assert_eq!( + artifact["rotation"]["fragmentComplete"], false, + "{artifact_id} cannot claim a complete retained fragment" + ); + assert_eq!( + (bytes_copied, file_size, byte_limit), + (byte_limit, byte_limit, byte_limit), + "{artifact_id} must retain the exact inclusive prefix through byteLimit" + ); + } +} + +#[test] +fn physical_evidence_is_explicitly_synthetic_and_sanitized() { + for (family, scenarios) in family_scenarios() { + for scenario in scenarios { + let (scenario_root, manifest, _) = load_contract(family, scenario); + assert_eq!(manifest["syntheticFixture"], true); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + let Some(relative_path) = artifact["relativePath"].as_str() else { + continue; + }; + let text = std::fs::read_to_string(scenario_root.join(relative_path)) + .expect("synthetic evidence is readable"); + assert!( + text.contains("SYNTHETIC"), + "{family}/{scenario}/{relative_path} is not explicitly synthetic" + ); + for forbidden in ["S-1-5-", "C:\\", "/Users/", "@", ".com", ".net", ".org"] { + assert!( + !text.contains(forbidden), + "{family}/{scenario}/{relative_path} contains forbidden identity `{forbidden}`" + ); + } + } + } + } +} + +#[test] +fn same_minute_inventory_and_compliance_failures_remain_separate() { + let (inventory_root, inventory_manifest, inventory_expected) = + load_contract("inventory", "terminal-failures"); + let (compliance_root, compliance_manifest, compliance_expected) = + load_contract("compliance", "terminal-failures"); + + validate_contract( + "inventory", + "terminal-failures", + &inventory_root, + &inventory_manifest, + &inventory_expected, + ) + .expect("inventory terminal fixture is valid"); + validate_contract( + "compliance", + "terminal-failures", + &compliance_root, + &compliance_manifest, + &compliance_expected, + ) + .expect("compliance terminal fixture is valid"); + + let inventory_artifact = &inventory_manifest["artifacts"][0]; + let compliance_artifact = &compliance_manifest["artifacts"][0]; + let inventory_text = std::fs::read_to_string( + inventory_root.join( + inventory_artifact["relativePath"] + .as_str() + .expect("inventory relativePath"), + ), + ) + .expect("inventory evidence is readable"); + let compliance_text = std::fs::read_to_string( + compliance_root.join( + compliance_artifact["relativePath"] + .as_str() + .expect("compliance relativePath"), + ), + ) + .expect("compliance evidence is readable"); + let (inventory_entries, inventory_errors) = + cmtraceopen_parser::parser::ccm::parse_content(&inventory_text, "inventory", None); + let (compliance_entries, compliance_errors) = + cmtraceopen_parser::parser::ccm::parse_content(&compliance_text, "compliance", None); + + assert_eq!(inventory_errors, 0); + assert_eq!(compliance_errors, 0); + assert_eq!(inventory_entries.len(), 1); + assert_eq!(compliance_entries.len(), 1); + assert_eq!( + inventory_entries[0].timestamp, compliance_entries[0].timestamp, + "the adversarial failures intentionally share the same source minute" + ); + assert_eq!( + inventory_expected["transactions"][0]["workflow"], + "inventory" + ); + assert_eq!( + compliance_expected["transactions"][0]["workflow"], + "compliance" + ); + assert!( + inventory_expected["transactions"][0]["key"]["CiId"].is_null(), + "inventory cannot borrow a compliance identifier" + ); + assert!( + compliance_expected["transactions"][0]["key"]["InventoryCycleId"].is_null(), + "compliance cannot borrow an inventory cycle identifier" + ); +} + +#[test] +fn every_scenario_satisfies_the_preparation_contract() { + for (family, scenarios) in family_scenarios() { + for scenario in scenarios { + let (scenario_root, manifest, expected) = load_contract(family, scenario); + validate_contract(family, scenario, &scenario_root, &manifest, &expected) + .unwrap_or_else(|error| panic!("{family}/{scenario}: {error}")); + } + } +} + +#[test] +fn dynamic_manifest_mutations_cannot_escape_source_and_identity_boundaries() { + let (scenario_root, manifest, expected) = load_contract("inventory", "success"); + + let mut role_swap = manifest.clone(); + role_swap["bundle"]["role"] = json!("server"); + assert_rejected( + "bundle role swap", + "inventory", + "success", + &scenario_root, + &role_swap, + &expected, + ); + + let mut family_swap = manifest.clone(); + family_swap["artifacts"][0]["designOnlyCatalog"]["entryId"] = json!("client-compliance"); + assert_rejected( + "logical family swap", + "inventory", + "success", + &scenario_root, + &family_swap, + &expected, + ); + + let mut source_injection = manifest.clone(); + source_injection["artifacts"][0]["originalBasename"] = json!("CIAgent.log"); + assert_rejected( + "foreign source injection", + "inventory", + "success", + &scenario_root, + &source_injection, + &expected, + ); + + let mut path_escape = manifest.clone(); + path_escape["artifacts"][0]["relativePath"] = json!("../outside.log"); + assert_rejected( + "relative path escape", + "inventory", + "success", + &scenario_root, + &path_escape, + &expected, + ); + + let mut wrong_bytes = manifest.clone(); + wrong_bytes["artifacts"][0]["bytesCopied"] = + json!(manifest["artifacts"][0]["bytesCopied"].as_u64().unwrap() + 1); + assert_rejected( + "incorrect copied byte count", + "inventory", + "success", + &scenario_root, + &wrong_bytes, + &expected, + ); +} + +#[test] +fn review_blocker_missing_capture_timestamp_is_rejected() { + let (scenario_root, mut manifest, expected) = load_contract("inventory", "success"); + manifest["artifacts"][0]["capturedUtc"] = Value::Null; + assert_rejected_with( + "missing capturedUtc", + "inventory", + "success", + &scenario_root, + &manifest, + &expected, + "capturedUtc", + ); +} + +#[test] +fn independent_review_blocker_cited_timestamp_cannot_follow_capture() { + let (scenario_root, mut manifest, expected) = load_contract("inventory", "success"); + for artifact in manifest["artifacts"] + .as_array_mut() + .expect("manifest artifacts are an array") + { + artifact["capturedUtc"] = json!("2026-07-30T00:00:00Z"); + } + assert_rejected_with( + "complete cited record after capture", + "inventory", + "success", + &scenario_root, + &manifest, + &expected, + "after capturedUtc", + ); +} + +#[test] +fn independent_review_blocker_failed_scenarios_require_exact_next_artifacts() { + let mut failures = Vec::new(); + for family in ["inventory", "compliance", "metering"] { + let (scenario_root, manifest, expected) = load_contract(family, "terminal-failures"); + for transaction_index in 0..expected["transactions"] + .as_array() + .expect("transactions are an array") + .len() + { + let phase = expected["transactions"][transaction_index]["phase"] + .as_str() + .expect("phase is a string"); + let mut mutated = expected.clone(); + mutated["transactions"][transaction_index]["nextArtifact"] = Value::Null; + match validate_contract( + family, + "terminal-failures", + &scenario_root, + &manifest, + &mutated, + ) { + Err(error) if error.contains("required nextArtifact") => {} + Err(error) => failures.push(format!("{family}/{phase}: wrong rejection: {error}")), + Ok(()) => failures.push(format!("{family}/{phase}: erased request was accepted")), + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn independent_review_blocker_nonfailures_reject_spurious_next_artifacts() { + let mut failures = Vec::new(); + for (family, scenario) in [ + ("inventory", "success"), + ("inventory", "recovery-contradictory"), + ("inventory", "same-minute-collision"), + ("compliance", "success"), + ("compliance", "noncompliant-result"), + ("compliance", "remediation-success"), + ("compliance", "recovery-contradictory"), + ("compliance", "same-minute-collision"), + ("metering", "success"), + ("metering", "recovery-contradictory"), + ("metering", "same-minute-collision"), + ] { + let (scenario_root, manifest, expected) = load_contract(family, scenario); + for transaction_index in 0..expected["transactions"] + .as_array() + .expect("transactions are an array") + .len() + { + let transaction_id = expected["transactions"][transaction_index]["transactionId"] + .as_str() + .expect("transactionId is a string"); + let mut mutated = expected.clone(); + mutated["transactions"][transaction_index]["nextArtifact"] = json!({ + "logicalArtifactId": expected_logical_artifact(family).expect("known family"), + "sourceBasename": admitted_sources(family).expect("known family")[0], + "reason": "Inspect the same exact key in this admitted workflow source." + }); + match validate_contract(family, scenario, &scenario_root, &manifest, &mutated) { + Err(error) if error.contains("spurious nextArtifact") => {} + Err(error) => failures.push(format!("{transaction_id}: wrong rejection: {error}")), + Ok(()) => failures.push(format!("{transaction_id}: spurious request was accepted")), + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn independent_review_blocker_last_success_respects_family_phase_order() { + let (scenario_root, manifest, mut expected) = load_contract("inventory", "terminal-failures"); + expected["transactions"][0]["lastSuccessfulPhase"] = json!("Report"); + assert_rejected_with( + "Collect failure claims later Report success", + "inventory", + "terminal-failures", + &scenario_root, + &manifest, + &expected, + "lastSuccessfulPhase", + ); +} + +#[test] +fn independent_review_blocker_recovery_uses_additive_signless_offset_ordering() { + let (temporary, manifest, expected) = copied_inventory_recovery_with_time_replacements( + "signless-offset", + &[ + ("time=\"01:20:00.000+000\"", "time=\"10:00:00.000240\""), + ("time=\"01:20:01.000+000\"", "time=\"07:00:00.000+000\""), + ], + ); + validate_contract( + "inventory", + "recovery-contradictory", + &temporary.root, + &manifest, + &expected, + ) + .unwrap_or_else(|error| { + panic!("valid signless +240 SCCM provenance must order recovery: {error}") + }); +} + +#[test] +fn independent_review_blocker_missing_additive_timestamp_provenance_is_rejected() { + let (temporary, manifest, expected) = copied_inventory_recovery_with_time_replacements( + "missing-offset", + &[ + ("time=\"01:20:00.000+000\"", "time=\"06:00:00.0001234\""), + ("time=\"01:20:01.000+000\"", "time=\"07:00:00.000+000\""), + ], + ); + assert_rejected_with( + "recovery with missing additive offset", + "inventory", + "recovery-contradictory", + &temporary.root, + &manifest, + &expected, + "normalized additive SCCM timestamp provenance", + ); +} + +#[test] +fn independent_review_blocker_invalid_additive_timestamp_provenance_is_rejected() { + let (temporary, manifest, mut expected) = copied_inventory_recovery_with_time_replacements( + "invalid-offset", + &[ + ("time=\"01:20:00.000+000\"", "time=\"06:00:00.000+99999\""), + ("time=\"01:20:01.000+000\"", "time=\"07:00:00.000+000\""), + ], + ); + let artifact_id = manifest["artifacts"][0]["artifactId"] + .as_str() + .expect("artifactId is a string"); + expected["sourceLocalObservations"] + .as_array_mut() + .expect("sourceLocalObservations are an array") + .push(json!({ + "observationId": "inventory-recovery-invalid-offset", + "kind": "invalidOffset", + "artifactIds": [artifact_id], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Invalid timestamp offset cannot support ordered or high-confidence workflow claims." + })); + assert_rejected_with( + "recovery with invalid additive offset", + "inventory", + "recovery-contradictory", + &temporary.root, + &manifest, + &expected, + "normalized additive SCCM timestamp provenance", + ); +} + +#[test] +fn exact_head_review_blocker_structured_fields_are_unique_in_one_ccm_envelope() { + let cases = [ + ( + "duplicate-report-id", + "ReportId=INV-REPORT-001", + "ReportId=INV-REPORT-001 ReportId=INV-REPORT-SHADOW", + "duplicate structured field", + ), + ( + "duplicate-phase", + "Phase=Report", + "Phase=Report Phase=Collect", + "duplicate structured field", + ), + ( + "duplicate-terminal", + "Terminal=true", + "Terminal=true Terminal=false", + "duplicate structured field", + ), + ( + "duplicate-family", + "Family=inventory", + "Family=server Family=inventory", + "duplicate structured field", + ), + ( + "nested-envelope", + "]LOG]!> {} + Err(error) => failures.push(format!("{label}: wrong rejection: {error}")), + Ok(()) => failures.push(format!("{label}: ambiguous record was accepted")), + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn exact_head_review_blocker_compliance_result_type_is_source_record_local() { + let (source_root, mut manifest, mut expected) = + load_contract("compliance", "noncompliant-result"); + let temporary = TemporaryScenario::copy_from(&source_root, "borrowed-result-type"); + let contents = concat!( + "\n", + "", + "\n", + ); + rewrite_artifact_by_id( + &temporary.root, + &mut manifest, + "compliance-noncompliant-result-agent-current", + contents, + ); + expected["transactions"][0]["evidence"] = json!([ + { + "artifactId": "compliance-noncompliant-result-agent-current", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "compliance-noncompliant-result-agent-current", + "startLine": 2, + "endLine": 2 + } + ]); + assert_rejected_with( + "ResultType borrowed from a nonterminal Report record", + "compliance", + "noncompliant-result", + &temporary.root, + &manifest, + &expected, + "compliance evaluation result is not source-record-local", + ); +} + +#[test] +fn exact_head_review_blocker_failed_last_success_is_cited_not_synthesized() { + let cases = [ + ("inventory", "inventory-provider-failed", "Collect"), + ("inventory", "inventory-serialize-failed", "Provider"), + ("inventory", "inventory-queue-failed", "Serialize"), + ("inventory", "inventory-report-failed", "Queue"), + ("compliance", "compliance-remediate-failed", "Evaluate"), + ("compliance", "compliance-report-failed", "Remediate"), + ("metering", "metering-aggregate-failed", "Collect"), + ("metering", "metering-report-failed", "Aggregate"), + ]; + let mut failures = Vec::new(); + for (family, transaction_id, uncited_phase) in cases { + let (scenario_root, manifest, mut expected) = load_contract(family, "terminal-failures"); + let transaction = expected["transactions"] + .as_array_mut() + .expect("transactions are an array") + .iter_mut() + .find(|transaction| transaction["transactionId"] == transaction_id) + .unwrap_or_else(|| panic!("fixture contains transaction {transaction_id}")); + transaction["lastSuccessfulPhase"] = json!(uncited_phase); + match validate_contract( + family, + "terminal-failures", + &scenario_root, + &manifest, + &expected, + ) { + Err(error) if error.contains("not evidence-backed") => {} + Err(error) => failures.push(format!("{transaction_id}: wrong rejection: {error}")), + Ok(()) => failures.push(format!( + "{transaction_id}: uncited predecessor {uncited_phase} was accepted" + )), + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn exact_head_review_blocker_observation_kind_matches_artifact_coverage() { + let (success_root, success_manifest, mut success_expected) = + load_contract("inventory", "success"); + success_expected["sourceLocalObservations"] = json!([{ + "observationId": "inventory-captured-as-gap", + "kind": "coverageGap", + "artifactIds": ["inventory-success-report-current"], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "All non-complete source states remain coverage only; no workflow outcome is inferred." + }]); + assert_rejected_with( + "captured artifact recast as coverage gap", + "inventory", + "success", + &success_root, + &success_manifest, + &success_expected, + "coverageGap is incompatible", + ); + + let (coverage_root, coverage_manifest, mut coverage_expected) = + load_contract("inventory", "coverage-states"); + coverage_expected["sourceLocalObservations"][0]["kind"] = json!("rotationSplit"); + coverage_expected["sourceLocalObservations"][0]["claim"] = json!( + "Exact keys split only across incomplete rotation fragments cannot establish a complete workflow." + ); + assert_rejected_with( + "all gap states recast as rotation split", + "inventory", + "coverage-states", + &coverage_root, + &coverage_manifest, + &coverage_expected, + "rotationSplit is incompatible", + ); +} + +#[test] +fn exact_head_review_blocker_output_schema_and_noncausal_vocabulary_are_closed() { + let (success_root, success_manifest, success_expected) = load_contract("inventory", "success"); + let mut failures = Vec::new(); + + let mut transaction_extension = success_expected.clone(); + transaction_extension["transactions"][0]["serverCause"] = json!("ManagementPoint"); + match validate_contract( + "inventory", + "success", + &success_root, + &success_manifest, + &transaction_extension, + ) { + Err(error) if error.contains("transaction fields") => {} + Err(error) => failures.push(format!("transaction extension: wrong rejection: {error}")), + Ok(()) => failures.push("transaction serverCause was accepted".to_owned()), + } + + let mut top_level_extension = success_expected.clone(); + top_level_extension["serverCause"] = json!("ManagementPoint"); + match validate_contract( + "inventory", + "success", + &success_root, + &success_manifest, + &top_level_extension, + ) { + Err(error) if error.contains("expected fields") => {} + Err(error) => failures.push(format!("top-level extension: wrong rejection: {error}")), + Ok(()) => failures.push("top-level serverCause was accepted".to_owned()), + } + + let (coverage_root, coverage_manifest, coverage_expected) = + load_contract("inventory", "coverage-states"); + let mut observation_extension = coverage_expected.clone(); + observation_extension["sourceLocalObservations"][0]["serverRole"] = json!("managementPoint"); + match validate_contract( + "inventory", + "coverage-states", + &coverage_root, + &coverage_manifest, + &observation_extension, + ) { + Err(error) if error.contains("observation fields") => {} + Err(error) => failures.push(format!("observation extension: wrong rejection: {error}")), + Ok(()) => failures.push("observation serverRole was accepted".to_owned()), + } + + let mut causal_synonyms = coverage_expected.clone(); + causal_synonyms["sourceLocalObservations"][0]["claim"] = + json!("A management-point outage triggered and explains the client result."); + match validate_contract( + "inventory", + "coverage-states", + &coverage_root, + &coverage_manifest, + &causal_synonyms, + ) { + Err(error) if error.contains("claim is not canonical") => {} + Err(error) => failures.push(format!("causal synonyms: wrong rejection: {error}")), + Ok(()) => failures.push("causal triggered/explains claim was accepted".to_owned()), + } + + let mut rewritten_prohibitions = success_expected.clone(); + rewritten_prohibitions["prohibitedClaims"] = json!([ + "missing evidence proves success", + "same-time records prove causation", + "client evidence proves a server outage", + "this corpus is live Windows acceptance" + ]); + match validate_contract( + "inventory", + "success", + &success_root, + &success_manifest, + &rewritten_prohibitions, + ) { + Err(error) if error.contains("prohibitedClaims") => {} + Err(error) => failures.push(format!("prohibited claims: wrong rejection: {error}")), + Ok(()) => failures.push("affirmative prohibitedClaims were accepted".to_owned()), + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn exact_head_review_blocker_transaction_identity_and_collision_topology_are_unique() { + let (scenario_root, manifest, mut expected) = + load_contract("inventory", "same-minute-collision"); + expected["transactions"][1]["key"] = expected["transactions"][0]["key"].clone(); + expected["transactions"][1]["evidence"] = expected["transactions"][0]["evidence"].clone(); + assert_rejected_with( + "different transaction IDs duplicate one exact key and evidence", + "inventory", + "same-minute-collision", + &scenario_root, + &manifest, + &expected, + "duplicate logical transaction identity", + ); + + let (_, mut manifest, expected) = load_contract("inventory", "same-minute-collision"); + manifest["artifacts"][1]["sanitizedSourcePath"] = + manifest["artifacts"][0]["sanitizedSourcePath"].clone(); + assert_rejected_with( + "cross-root paths collapse while fingerprints differ", + "inventory", + "same-minute-collision", + &scenario_root, + &manifest, + &expected, + "source topology", + ); +} + +#[test] +fn exact_head_review_blocker_all_ordered_arrays_are_canonical() { + let mut failures = Vec::new(); + + let (collision_root, collision_manifest, mut collision_expected) = + load_contract("inventory", "same-minute-collision"); + collision_expected["transactions"] + .as_array_mut() + .expect("transactions are an array") + .reverse(); + match validate_contract( + "inventory", + "same-minute-collision", + &collision_root, + &collision_manifest, + &collision_expected, + ) { + Err(error) if error.contains("transaction order is not canonical") => {} + Err(error) => failures.push(format!("transaction order: wrong rejection: {error}")), + Ok(()) => failures.push("reversed transaction order was accepted".to_owned()), + } + + let (recovery_root, recovery_manifest, mut recovery_expected) = + load_contract("inventory", "recovery-contradictory"); + recovery_expected["transactions"][0]["evidence"] + .as_array_mut() + .expect("evidence is an array") + .reverse(); + match validate_contract( + "inventory", + "recovery-contradictory", + &recovery_root, + &recovery_manifest, + &recovery_expected, + ) { + Err(error) if error.contains("evidence order is not canonical") => {} + Err(error) => failures.push(format!("evidence order: wrong rejection: {error}")), + Ok(()) => failures.push("reversed evidence order was accepted".to_owned()), + } + + let (success_root, mut success_manifest, mut success_expected) = + load_contract("inventory", "success"); + success_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .reverse(); + success_expected["coverage"] + .as_array_mut() + .expect("coverage is an array") + .reverse(); + match validate_contract( + "inventory", + "success", + &success_root, + &success_manifest, + &success_expected, + ) { + Err(error) if error.contains("manifest artifact order is not canonical") => {} + Err(error) => failures.push(format!("manifest order: wrong rejection: {error}")), + Ok(()) => failures.push("reversed manifest/coverage order was accepted".to_owned()), + } + + let (_, success_manifest, mut success_expected) = load_contract("inventory", "success"); + success_expected["coverage"] + .as_array_mut() + .expect("coverage is an array") + .reverse(); + match validate_contract( + "inventory", + "success", + &success_root, + &success_manifest, + &success_expected, + ) { + Err(error) if error.contains("coverage order is not canonical") => {} + Err(error) => failures.push(format!("coverage order: wrong rejection: {error}")), + Ok(()) => failures.push("reversed coverage order was accepted".to_owned()), + } + + let (profile_root, profile_manifest, mut profile_expected) = + load_contract("compliance", "malformed-unknown-profile-invalid-offset"); + profile_expected["sourceLocalObservations"] + .as_array_mut() + .expect("observations are an array") + .reverse(); + match validate_contract( + "compliance", + "malformed-unknown-profile-invalid-offset", + &profile_root, + &profile_manifest, + &profile_expected, + ) { + Err(error) if error.contains("observation order is not canonical") => {} + Err(error) => failures.push(format!("observation order: wrong rejection: {error}")), + Ok(()) => failures.push("reversed observation order was accepted".to_owned()), + } + + let (coverage_root, coverage_manifest, mut coverage_expected) = + load_contract("inventory", "coverage-states"); + coverage_expected["sourceLocalObservations"][0]["artifactIds"] + .as_array_mut() + .expect("artifactIds are an array") + .reverse(); + match validate_contract( + "inventory", + "coverage-states", + &coverage_root, + &coverage_manifest, + &coverage_expected, + ) { + Err(error) if error.contains("observation artifact order is not canonical") => {} + Err(error) => failures.push(format!( + "observation artifact order: wrong rejection: {error}" + )), + Ok(()) => failures.push("reversed observation artifact order was accepted".to_owned()), + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn dynamic_evidence_mutations_cannot_fabricate_exact_or_high_confidence_facts() { + let (scenario_root, manifest, expected) = load_contract("inventory", "success"); + + let mut unbound_key = expected.clone(); + unbound_key["transactions"][0]["key"]["ReportId"] = json!("INV-REPORT-NOT-CITED"); + assert_rejected( + "uncited key value", + "inventory", + "success", + &scenario_root, + &manifest, + &unbound_key, + ); + + let mut cross_family_key = expected.clone(); + cross_family_key["transactions"][0]["key"]["CiId"] = json!("CI-INJECTED"); + assert_rejected( + "cross-family key", + "inventory", + "success", + &scenario_root, + &manifest, + &cross_family_key, + ); + + let mut wrong_phase_line = expected.clone(); + wrong_phase_line["transactions"][0]["evidence"][0]["startLine"] = json!(1); + wrong_phase_line["transactions"][0]["evidence"][0]["endLine"] = json!(1); + assert_rejected( + "phase borrowed from another line", + "inventory", + "success", + &scenario_root, + &manifest, + &wrong_phase_line, + ); + + let mut unknown_profile = manifest.clone(); + unknown_profile["artifacts"][2]["sourceVersion"] = json!("9.99.UNKNOWN"); + assert_rejected( + "unknown source version at high confidence", + "inventory", + "success", + &scenario_root, + &unknown_profile, + &expected, + ); + + let mut broad_next_artifact = load_contract("inventory", "terminal-failures").2; + broad_next_artifact["transactions"][0]["nextArtifact"]["reason"] = + json!("Recursively scan C:\\ and every log on every volume *"); + let (terminal_root, terminal_manifest, _) = load_contract("inventory", "terminal-failures"); + assert_rejected( + "unbounded next-artifact instruction", + "inventory", + "terminal-failures", + &terminal_root, + &terminal_manifest, + &broad_next_artifact, + ); +} + +#[test] +fn exact_message_tokens_reject_key_and_semantic_lookalikes() { + let complete_record = |payload: &str| { + format!( + "" + ) + }; + for (field, value) in [ + ("ReportId", "INV-REPORT-001"), + ("Phase", "Report"), + ("Disposition", "Succeeded"), + ("Terminal", "true"), + ("ResultType", "Evaluation"), + ] { + let exact = complete_record(&format!("{field}={value}")); + let exact_fields = + strict_ccm_structured_fields(&exact, "exact token").expect("exact record is valid"); + assert_eq!(exact_fields.get(field).map(String::as_str), Some(value)); + + for lookalike in [ + complete_record(&format!("Other{field}={value}")), + complete_record(&format!("Prefix{field}={value}")), + complete_record(&format!("{field}={value}-suffix")), + complete_record(&format!("X={field}={value}")), + ] { + let fields = strict_ccm_structured_fields(&lookalike, "look-alike token") + .expect("look-alike is still one complete record"); + assert!( + fields.get(field).is_none_or(|actual| actual != value), + "look-alike {field} token was accepted: {lookalike}" + ); + } + } +} + +#[test] +fn noncapture_fragment_marker_matches_issue_319_preparation_schema() { + for family in ["inventory", "compliance", "metering"] { + let (_, manifest, _) = load_contract(family, "coverage-states"); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .filter(|artifact| { + matches!( + artifact["captureState"].as_str(), + Some("absent" | "accessDenied" | "skipped" | "unsupported") + ) + }) + { + let artifact_id = artifact["artifactId"] + .as_str() + .expect("noncapture artifactId is a string"); + assert_eq!(artifact["bytesCopied"], 0, "{artifact_id}"); + assert!(artifact["relativePath"].is_null(), "{artifact_id}"); + assert!(artifact.get("encoding").is_none(), "{artifact_id}"); + assert!(artifact.get("collectionLimit").is_none(), "{artifact_id}"); + assert_eq!( + artifact["rotation"], + json!({"kind": "current", "fragmentComplete": false}), + "{artifact_id} must mirror #319's proposed noncapture marker" + ); + } + } +} + +#[test] +fn dynamic_recovery_mutations_require_selected_profile_and_usable_offset() { + let (unknown_root, mut unknown_manifest, mut unknown_expected) = + load_contract("inventory", "recovery-contradictory"); + let unknown_artifact_id = unknown_manifest["artifacts"][0]["artifactId"] + .as_str() + .expect("artifactId") + .to_owned(); + unknown_manifest["artifacts"][0]["sourceVersion"] = json!("9.99.UNKNOWN"); + unknown_expected["extractionProfile"]["selectionState"] = json!("mixedKnownAndUnknown"); + unknown_expected["sourceLocalObservations"] + .as_array_mut() + .expect("sourceLocalObservations") + .push(json!({ + "observationId": "inventory-recovery-unknown-profile", + "kind": "unknownProfile", + "artifactIds": [unknown_artifact_id], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Unknown source version has no selected extraction profile." + })); + assert_rejected( + "medium recovery from unknown source profile", + "inventory", + "recovery-contradictory", + &unknown_root, + &unknown_manifest, + &unknown_expected, + ); + + let (offset_root, offset_manifest, mut offset_expected) = + load_contract("compliance", "malformed-unknown-profile-invalid-offset"); + offset_expected["transactions"] = json!([{ + "transactionId": "invalid-offset-recovery", + "workflow": "compliance", + "key": { + "CiId": "CI-041", + "BaselineId": "BASELINE-041", + "StateId": "STATE-041", + "ResourceHandle": "safe:resource:compliance-041", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "recovered", + "classification": "recovery", + "confidence": "medium", + "lastSuccessfulPhase": "Report", + "evidence": [ + { + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", + "startLine": 2, + "endLine": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifact": null + }]); + assert_rejected( + "medium recovery from invalid timestamp offsets", + "compliance", + "malformed-unknown-profile-invalid-offset", + &offset_root, + &offset_manifest, + &offset_expected, + ); +} + +#[test] +fn review_blocker_same_timestamp_opposites_cannot_be_recovery() { + let (scenario_root, manifest, mut expected) = + load_contract("inventory", "recovery-contradictory"); + expected["transactions"][0]["key"] = expected["transactions"][1]["key"].clone(); + expected["transactions"][0]["evidence"] = expected["transactions"][1]["evidence"].clone(); + assert_rejected_with( + "same-timestamp opposites relabeled recovery", + "inventory", + "recovery-contradictory", + &scenario_root, + &manifest, + &expected, + "strictly ordered", + ); +} + +#[test] +fn review_blocker_opposing_terminal_records_cannot_be_promoted_high() { + let (scenario_root, manifest, expected) = load_contract("inventory", "recovery-contradictory"); + + let mut promoted_failure = expected.clone(); + promoted_failure["transactions"][0]["state"] = json!("failed"); + promoted_failure["transactions"][0]["classification"] = json!("confirmedFailure"); + promoted_failure["transactions"][0]["confidence"] = json!("high"); + promoted_failure["transactions"][0]["lastSuccessfulPhase"] = json!("Queue"); + assert_rejected_with( + "opposing terminals promoted to confirmed failure", + "inventory", + "recovery-contradictory", + &scenario_root, + &manifest, + &promoted_failure, + "opposing terminal evidence", + ); + + let mut promoted_success = expected.clone(); + promoted_success["transactions"][0]["state"] = json!("succeeded"); + promoted_success["transactions"][0]["classification"] = json!("success"); + promoted_success["transactions"][0]["confidence"] = json!("high"); + assert_rejected_with( + "opposing terminals promoted to success", + "inventory", + "recovery-contradictory", + &scenario_root, + &manifest, + &promoted_success, + "opposing terminal evidence", + ); +} + +#[test] +fn dynamic_coverage_and_collision_mutations_remain_noncausal() { + let (coverage_root, coverage_manifest, mut coverage_expected) = + load_contract("metering", "coverage-states"); + coverage_expected["coverage"][0]["state"] = json!("captured"); + assert_rejected( + "missing artifact promoted to captured", + "metering", + "coverage-states", + &coverage_root, + &coverage_manifest, + &coverage_expected, + ); + + let (noncompliant_root, noncompliant_manifest, mut noncompliant_expected) = + load_contract("compliance", "noncompliant-result"); + noncompliant_expected["transactions"][0]["classification"] = json!("confirmedFailure"); + noncompliant_expected["transactions"][0]["state"] = json!("failed"); + assert_rejected( + "noncompliant result promoted to failure", + "compliance", + "noncompliant-result", + &noncompliant_root, + &noncompliant_manifest, + &noncompliant_expected, + ); + + let (collision_root, mut collision_manifest, collision_expected) = + load_contract("inventory", "same-minute-collision"); + collision_manifest["artifacts"][1]["pathFingerprint"] = + collision_manifest["artifacts"][0]["pathFingerprint"].clone(); + assert_rejected( + "cross-root fingerprint alias", + "inventory", + "same-minute-collision", + &collision_root, + &collision_manifest, + &collision_expected, + ); + + let (_, collision_manifest, mut collision_expected) = + load_contract("inventory", "same-minute-collision"); + collision_expected["transactions"][1]["key"] = + collision_expected["transactions"][0]["key"].clone(); + assert_rejected( + "same-minute key borrowing", + "inventory", + "same-minute-collision", + &collision_root, + &collision_manifest, + &collision_expected, + ); +} + +#[test] +fn review_blocker_source_local_observations_reject_causal_language() { + let (scenario_root, manifest, mut expected) = load_contract("inventory", "coverage-states"); + expected["sourceLocalObservations"][0]["claim"] = + json!("The client root cause caused the failure."); + assert_rejected_with( + "source-local causal claim", + "inventory", + "coverage-states", + &scenario_root, + &manifest, + &expected, + "claim is not canonical", + ); +} + +#[test] +fn review_blocker_required_scenario_semantics_cannot_be_erased() { + for (family, scenario) in [ + ("inventory", "success"), + ("inventory", "terminal-failures"), + ("compliance", "noncompliant-result"), + ("metering", "success"), + ] { + let (scenario_root, manifest, mut expected) = load_contract(family, scenario); + expected["transactions"] = json!([]); + assert_rejected_with( + "required scenario transactions erased", + family, + scenario, + &scenario_root, + &manifest, + &expected, + "required scenario semantics", + ); + } +} + +#[test] +fn invalid_timestamp_offsets_cannot_be_promoted_to_high_confidence() { + let (scenario_root, manifest, mut expected) = + load_contract("compliance", "malformed-unknown-profile-invalid-offset"); + expected["transactions"] = json!([{ + "transactionId": "invalid-offset-promotion", + "workflow": "compliance", + "key": { + "CiId": "CI-041", + "BaselineId": "BASELINE-041", + "StateId": "STATE-041", + "ResourceHandle": "safe:resource:compliance-041", + "keyProfileKind": "complianceExact", + "extractionProfileId": "sccm-client-compliance-5.00.test-v1", + "confidence": "exact" + }, + "phase": "Report", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "lastSuccessfulPhase": "Report", + "evidence": [{ + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", + "startLine": 1, + "endLine": 1 + }], + "coverageGapArtifactIds": [], + "nextArtifact": null + }]); + assert_rejected( + "invalid offset promoted to high confidence", + "compliance", + "malformed-unknown-profile-invalid-offset", + &scenario_root, + &manifest, + &expected, + ); +} + +#[test] +fn review_blocker_physical_identity_and_cross_root_topology_are_closed() { + let mut failures = Vec::new(); + for (family, target_id, source_id) in [ + ( + "inventory", + "inventory-coverage-states-skipped", + "inventory-coverage-states-partial", + ), + ( + "inventory", + "inventory-coverage-states-unsupported", + "inventory-coverage-states-access-denied", + ), + ( + "compliance", + "compliance-coverage-states-access-denied", + "compliance-coverage-states-partial", + ), + ] { + let (scenario_root, mut manifest, expected) = load_contract(family, "coverage-states"); + let artifacts = manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array"); + let source_path = artifacts + .iter() + .find(|artifact| artifact["artifactId"] == source_id) + .expect("source artifact exists")["sanitizedSourcePath"] + .clone(); + let source_root = source_path + .as_str() + .expect("source path is a string") + .strip_prefix("SYNTHETIC://") + .expect("source path is synthetic") + .split('/') + .next() + .expect("source path has a root") + .to_owned(); + let target = artifacts + .iter_mut() + .find(|artifact| artifact["artifactId"] == target_id) + .expect("target artifact exists"); + target["sanitizedSourcePath"] = source_path; + target["pathFingerprint"] = json!(format!("synthetic-{target_id}-{source_root}")); + collect_contract_rejection( + &mut failures, + &format!("{family} contradictory physical identity {target_id}/{source_id}"), + validate_contract( + family, + "coverage-states", + &scenario_root, + &manifest, + &expected, + ), + "duplicate physical source identity", + ); + } + + let (scenario_root, mut collapsed_manifest, expected) = + load_contract("inventory", "same-minute-collision"); + collapsed_manifest["artifacts"][1]["sanitizedSourcePath"] = + json!("SYNTHETIC://root-a/alternate/CCM/Logs/InventoryAgentProvider.log"); + collect_contract_rejection( + &mut failures, + "cross-root source collapsed beneath an alternate root-a path", + validate_contract( + "inventory", + "same-minute-collision", + &scenario_root, + &collapsed_manifest, + &expected, + ), + "source topology", + ); + + let (_, mut swapped_manifest, expected) = load_contract("inventory", "same-minute-collision"); + let first_fingerprint = swapped_manifest["artifacts"][0]["pathFingerprint"].clone(); + swapped_manifest["artifacts"][0]["pathFingerprint"] = + swapped_manifest["artifacts"][1]["pathFingerprint"].clone(); + swapped_manifest["artifacts"][1]["pathFingerprint"] = first_fingerprint; + collect_contract_rejection( + &mut failures, + "cross-root fingerprints swapped between exact source handles", + validate_contract( + "inventory", + "same-minute-collision", + &scenario_root, + &swapped_manifest, + &expected, + ), + "source topology", + ); + + let (success_root, mut same_root_manifest, success_expected) = + load_contract("inventory", "success"); + let first_fingerprint = same_root_manifest["artifacts"][0]["pathFingerprint"].clone(); + same_root_manifest["artifacts"][0]["pathFingerprint"] = + same_root_manifest["artifacts"][1]["pathFingerprint"].clone(); + same_root_manifest["artifacts"][1]["pathFingerprint"] = first_fingerprint; + collect_contract_rejection( + &mut failures, + "same-root fingerprints swapped between artifact handles", + validate_contract( + "inventory", + "success", + &success_root, + &same_root_manifest, + &success_expected, + ), + "source topology", + ); + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_manifest_and_ccm_structured_vocabularies_are_closed() { + let mut failures = Vec::new(); + for (label, injected_field) in [ + ("server cause", "ServerCause=ManagementPoint"), + ("server role", "Role=server"), + ("foreign workflow", "Workflow=compliance"), + ] { + let replacement = format!("{injected_field} Phase=Report"); + let (temporary, manifest, expected) = copied_contract_with_evidence_replacements( + "inventory", + "success", + "inventory-success-report-current", + label, + &[("Phase=Report", &replacement)], + ); + collect_contract_rejection( + &mut failures, + &format!("unknown CCM field {injected_field}"), + validate_contract( + "inventory", + "success", + &temporary.root, + &manifest, + &expected, + ), + "unadmitted structured field", + ); + } + + let (scenario_root, mut manifest, expected) = load_contract("inventory", "success"); + manifest["artifacts"][0]["serverCause"] = json!("ManagementPoint"); + collect_contract_rejection( + &mut failures, + "undeclared SCCM manifest artifact serverCause", + validate_contract("inventory", "success", &scenario_root, &manifest, &expected), + "artifact fields", + ); + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_bundle_identity_and_order_descriptors_are_closed() { + let (scenario_root, manifest, expected) = load_contract("inventory", "success"); + let mutations = [ + ( + "foreign-scenario bundleId", + "bundleId", + json!("sccm-325-inventory-terminal-failures"), + ), + ( + "arbitrary-suffix bundleId", + "bundleId", + json!("sccm-325-inventory-anything"), + ), + ("boolean artifactOrder", "artifactOrder", json!(false)), + ( + "altered artifactOrder", + "artifactOrder", + json!("artifactId,originalBasename"), + ), + ("boolean rotationOrder", "rotationOrder", json!(false)), + ( + "altered rotationOrder", + "rotationOrder", + json!("timestamp-descending,current"), + ), + ]; + let mut failures = Vec::new(); + + for (label, field, value) in mutations { + let mut mutated = manifest.clone(); + mutated["bundle"][field] = value; + collect_contract_rejection( + &mut failures, + label, + validate_contract("inventory", "success", &scenario_root, &mutated, &expected), + field, + ); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_public_identities_are_nonempty_control_free_and_scoped() { + let mut failures = Vec::new(); + + for (label, replacement) in [ + ("blank artifactId", String::new()), + ( + "control-bearing artifactId", + "metering-success\nforeign".to_owned(), + ), + ( + "foreign-family artifactId", + "inventory-success-report-current".to_owned(), + ), + ( + "overlong artifactId", + format!("metering-success-{}", "a".repeat(112)), + ), + ] { + let (scenario_root, mut manifest, mut expected) = load_contract("metering", "success"); + manifest["artifacts"][0]["artifactId"] = json!(&replacement); + manifest["artifacts"][0]["pathFingerprint"] = + json!(format!("synthetic-{replacement}-root-a")); + expected["coverage"][0]["artifactId"] = json!(&replacement); + expected["transactions"][0]["evidence"][0]["artifactId"] = json!(&replacement); + collect_contract_rejection( + &mut failures, + label, + validate_contract("metering", "success", &scenario_root, &manifest, &expected), + "artifactId is not canonical", + ); + } + + for (label, replacement) in [ + ("blank transactionId", String::new()), + ( + "control-bearing transactionId", + "metering-success\nforeign".to_owned(), + ), + ( + "foreign-family transactionId", + "inventory-success".to_owned(), + ), + ( + "overlong transactionId", + format!("metering-{}", "a".repeat(120)), + ), + ] { + let (scenario_root, manifest, mut expected) = load_contract("metering", "success"); + expected["transactions"][0]["transactionId"] = json!(&replacement); + collect_contract_rejection( + &mut failures, + label, + validate_contract("metering", "success", &scenario_root, &manifest, &expected), + "transactionId is not canonical", + ); + } + + for (label, replacement) in [ + ("blank observationId", String::new()), + ( + "control-bearing observationId", + "metering-coverage-only\nforeign".to_owned(), + ), + ( + "foreign-family observationId", + "inventory-coverage-only".to_owned(), + ), + ( + "overlong observationId", + format!("metering-{}", "a".repeat(120)), + ), + ] { + let (scenario_root, manifest, mut expected) = load_contract("metering", "coverage-states"); + expected["sourceLocalObservations"][0]["observationId"] = json!(&replacement); + collect_contract_rejection( + &mut failures, + label, + validate_contract( + "metering", + "coverage-states", + &scenario_root, + &manifest, + &expected, + ), + "observationId is not canonical", + ); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_sources_own_exact_phases_and_workflow_semantics() { + let (temporary, manifest, mut expected) = copied_contract_with_evidence_replacements( + "inventory", + "success", + "inventory-success-agent-current", + "agent-borrows-report-phase", + &[( + "Phase=Collect Disposition=Succeeded Terminal=false", + "Phase=Report Disposition=Succeeded Terminal=true", + )], + ); + expected["transactions"][0]["evidence"] = json!([{ + "artifactId": "inventory-success-agent-current", + "startLine": 1, + "endLine": 1 + }]); + assert_rejected_with( + "InventoryAgent record relabeled as terminal Report success", + "inventory", + "success", + &temporary.root, + &manifest, + &expected, + "does not own phase", + ); +} + +#[test] +fn review_blocker_inventory_does_not_borrow_compliance_completion_semantics() { + let (scenario_root, manifest, _) = load_contract("inventory", "success"); + let artifacts = manifest["artifacts"] + .as_array() + .expect("artifacts are an array"); + let artifacts_by_id = artifacts + .iter() + .map(|artifact| { + ( + artifact["artifactId"] + .as_str() + .expect("artifactId is a string") + .to_owned(), + artifact, + ) + }) + .collect::>(); + let evidence = json!([{ + "artifactId": "inventory-success-agent-current", + "startLine": 1, + "endLine": 1 + }]); + let mut records = evidence_record_texts( + &scenario_root, + &artifacts_by_id, + evidence.as_array().expect("evidence is an array"), + ) + .expect("inventory collect evidence is readable"); + records[0] + .fields + .insert("Disposition".to_owned(), "NonCompliant".to_owned()); + records[0] + .fields + .insert("Terminal".to_owned(), "true".to_owned()); + records[0] + .fields + .insert("ResultType".to_owned(), "Evaluation".to_owned()); + assert_eq!( + evidence_backed_last_successful_phase( + &records, + admitted_phases("inventory").expect("inventory phases"), + "confirmedFailure", + ), + None, + "inventory must not infer a predecessor from compliance evaluation semantics" + ); +} + +#[test] +fn review_blocker_capture_state_provenance_is_closed() { + let mut failures = Vec::new(); + + let (success_root, mut captured_manifest, success_expected) = + load_contract("inventory", "success"); + captured_manifest["artifacts"][0]["collectionLimit"]["limitApplied"] = json!(true); + captured_manifest["artifacts"][0]["truncated"] = json!(true); + collect_contract_rejection( + &mut failures, + "captured source claims an applied truncating cap", + validate_contract( + "inventory", + "success", + &success_root, + &captured_manifest, + &success_expected, + ), + "captured state provenance", + ); + + let (coverage_root, mut nonphysical_manifest, coverage_expected) = + load_contract("inventory", "coverage-states"); + let access_denied = nonphysical_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "inventory-coverage-states-access-denied") + .expect("access-denied artifact exists"); + access_denied["encoding"] = json!("utf-8"); + access_denied["collectionLimit"] = json!({ + "byteLimit": 0, + "limitApplied": true + }); + access_denied["truncated"] = json!(true); + collect_contract_rejection( + &mut failures, + "accessDenied source invents encoding cap and truncation", + validate_contract( + "inventory", + "coverage-states", + &coverage_root, + &nonphysical_manifest, + &coverage_expected, + ), + "nonphysical state provenance", + ); + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_nonphysical_optional_field_json_types_are_closed() { + let (scenario_root, manifest, expected) = load_contract("inventory", "coverage-states"); + let mut failures = Vec::new(); + let mutations = vec![ + ( + "accessDenied.relativePath=false", + "accessDenied", + "relativePath", + json!(false), + "relativePath", + ), + ( + "skipped.relativePath=0", + "skipped", + "relativePath", + json!(0), + "relativePath", + ), + ( + "unsupported.relativePath=[]", + "unsupported", + "relativePath", + json!([]), + "relativePath", + ), + ( + "absent.relativePath={}", + "absent", + "relativePath", + json!({}), + "relativePath", + ), + ( + "accessDenied.sourceVersion=false", + "accessDenied", + "sourceVersion", + json!(false), + "sourceVersion", + ), + ( + "skipped.sourceVersion={unexpected:true}", + "skipped", + "sourceVersion", + json!({"unexpected": true}), + "sourceVersion", + ), + ( + "unsupported.sourceVersion=[]", + "unsupported", + "sourceVersion", + json!([]), + "sourceVersion", + ), + ( + "accessDenied.sanitizedSourcePath=false", + "accessDenied", + "sanitizedSourcePath", + json!(false), + "sanitizedSourcePath", + ), + ( + "skipped.pathFingerprint={}", + "skipped", + "pathFingerprint", + json!({}), + "pathFingerprint", + ), + ]; + + for (label, capture_state, field, value, required_error) in mutations { + let mut mutated = manifest.clone(); + let artifact = mutated["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["captureState"] == capture_state) + .unwrap_or_else(|| panic!("{capture_state} artifact exists")); + artifact[field] = value; + collect_contract_rejection( + &mut failures, + label, + validate_contract( + "inventory", + "coverage-states", + &scenario_root, + &mutated, + &expected, + ), + required_error, + ); + } + + for capture_state in ["accessDenied", "skipped", "unsupported"] { + let mut version_unknown = manifest.clone(); + let artifact = version_unknown["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["captureState"] == capture_state) + .unwrap_or_else(|| panic!("{capture_state} artifact exists")); + artifact["sourceVersion"] = Value::Null; + if let Err(error) = validate_contract( + "inventory", + "coverage-states", + &scenario_root, + &version_unknown, + &expected, + ) { + failures.push(format!( + "{capture_state}.sourceVersion=null: valid optional version was rejected: {error}" + )); + } + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_source_versions_are_canonical_before_profile_selection() { + let (scenario_root, manifest, expected) = load_contract("metering", "success"); + let mut failures = Vec::new(); + + for (label, source_version) in [ + ("blank sourceVersion", ""), + ( + "control-bearing sourceVersion", + "5.00.TEST.325\n9.99.UNKNOWN", + ), + ("whitespace-bearing sourceVersion", "5.00.TEST.325 "), + ("empty sourceVersion segment", "5.00.TEST..325"), + ] { + let mut mutated = manifest.clone(); + mutated["artifacts"][0]["sourceVersion"] = json!(source_version); + collect_contract_rejection( + &mut failures, + label, + validate_contract("metering", "success", &scenario_root, &mutated, &expected), + "sourceVersion is not canonical", + ); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_unknown_versions_are_profile_gaps_for_every_coverage_state() { + let (scenario_root, manifest, expected) = load_contract("inventory", "coverage-states"); + let mut failures = Vec::new(); + let versioned_artifacts = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .filter_map(|artifact| { + artifact["sourceVersion"].as_str().map(|_| { + ( + artifact["artifactId"] + .as_str() + .expect("artifactId is a string") + .to_owned(), + artifact["captureState"] + .as_str() + .expect("captureState is a string") + .to_owned(), + ) + }) + }) + .collect::>(); + + for (artifact_id, capture_state) in versioned_artifacts { + let mut unknown_manifest = manifest.clone(); + let artifact = unknown_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == artifact_id) + .unwrap_or_else(|| panic!("{artifact_id} exists")); + artifact["sourceVersion"] = json!("9.99.UNKNOWN"); + + collect_contract_rejection( + &mut failures, + &format!("{capture_state} unknown version without profile gap"), + validate_contract( + "inventory", + "coverage-states", + &scenario_root, + &unknown_manifest, + &expected, + ), + "profile selection", + ); + + let mut gap_expected = expected.clone(); + gap_expected["extractionProfile"]["selectionState"] = json!("mixedKnownAndUnknown"); + gap_expected["sourceLocalObservations"] + .as_array_mut() + .expect("sourceLocalObservations are an array") + .push(json!({ + "observationId": format!( + "inventory-coverage-unknown-profile-{artifact_id}" + ), + "kind": "unknownProfile", + "artifactIds": [artifact_id], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Unknown source version has no selected extraction profile." + })); + if let Err(error) = validate_contract( + "inventory", + "coverage-states", + &scenario_root, + &unknown_manifest, + &gap_expected, + ) { + failures.push(format!( + "{capture_state} unknown version with bounded profile gap was rejected: {error}" + )); + } + } + + let mut absent_version = manifest.clone(); + let absent = absent_version["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["captureState"] == "absent") + .expect("absent artifact exists"); + absent["sourceVersion"] = json!("9.99.UNKNOWN"); + collect_contract_rejection( + &mut failures, + "absent source invents an unknown version", + validate_contract( + "inventory", + "coverage-states", + &scenario_root, + &absent_version, + &expected, + ), + "absent source invents path/version identity", + ); + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_source_local_observation_memberships_are_unique_by_kind() { + let mut failures = Vec::new(); + + for (family, scenario, kind) in [ + ("metering", "coverage-states", "coverageGap"), + ( + "compliance", + "malformed-unknown-profile-invalid-offset", + "malformedRecord", + ), + ( + "compliance", + "malformed-unknown-profile-invalid-offset", + "invalidOffset", + ), + ("metering", "rotation-boundary", "rotationSplit"), + ] { + let (scenario_root, manifest, mut expected) = load_contract(family, scenario); + let observations = expected["sourceLocalObservations"] + .as_array_mut() + .expect("sourceLocalObservations are an array"); + let mut duplicate = observations + .iter() + .find(|observation| observation["kind"] == kind) + .unwrap_or_else(|| panic!("{family}/{scenario} contains {kind}")) + .clone(); + let observation_id = duplicate["observationId"] + .as_str() + .expect("observationId is a string"); + duplicate["observationId"] = json!(format!("{observation_id}-z")); + observations.push(duplicate); + observations.sort_by(|left, right| { + left["observationId"] + .as_str() + .cmp(&right["observationId"].as_str()) + }); + + collect_contract_rejection( + &mut failures, + &format!("duplicate {kind} membership"), + validate_contract(family, scenario, &scenario_root, &manifest, &expected), + "duplicate source-local observation", + ); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_rotation_split_requires_one_source_lineage_and_exact_key() { + let mut failures = Vec::new(); + + let (inventory_source_root, mut inventory_manifest, inventory_expected) = + load_contract("inventory", "rotation-boundary"); + let inventory_temporary = + TemporaryScenario::copy_from(&inventory_source_root, "rotation-basename-mismatch"); + let inventory_artifact = &mut inventory_manifest["artifacts"][0]; + let old_relative_path = inventory_artifact["relativePath"] + .as_str() + .expect("current inventory artifact relativePath is a string") + .to_owned(); + let new_relative_path = + "evidence/client-inventory/root-a/current/InventoryAgentProvider.log".to_owned(); + let new_full_path = inventory_temporary.root.join(&new_relative_path); + let provider_contents = "\n"; + std::fs::remove_file(inventory_temporary.root.join(old_relative_path)) + .expect("current inventory evidence can be replaced"); + std::fs::write(&new_full_path, provider_contents) + .expect("mismatched provider evidence can be written"); + inventory_artifact["originalBasename"] = json!("InventoryAgentProvider.log"); + inventory_artifact["sanitizedSourcePath"] = + json!("SYNTHETIC://root-a/CCM/Logs/InventoryAgentProvider.log"); + inventory_artifact["relativePath"] = json!(new_relative_path); + inventory_artifact["bytesCopied"] = json!(provider_contents.len() as u64); + collect_contract_rejection( + &mut failures, + "different canonical basenames form one rotation split", + validate_contract( + "inventory", + "rotation-boundary", + &inventory_temporary.root, + &inventory_manifest, + &inventory_expected, + ), + "rotationSplit lineage/key", + ); + + let (metering_root, mut version_manifest, mut version_expected) = + load_contract("metering", "rotation-boundary"); + let unknown_artifact_id = "metering-rotation-boundary-report-lo"; + version_manifest["artifacts"][1]["sourceVersion"] = json!("9.99.UNKNOWN"); + version_expected["extractionProfile"]["selectionState"] = json!("mixedKnownAndUnknown"); + version_expected["sourceLocalObservations"] + .as_array_mut() + .expect("sourceLocalObservations are an array") + .push(json!({ + "observationId": "metering-rotation-unknown-profile", + "kind": "unknownProfile", + "artifactIds": [unknown_artifact_id], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Unknown source version has no selected extraction profile." + })); + collect_contract_rejection( + &mut failures, + "different source versions form one rotation split", + validate_contract( + "metering", + "rotation-boundary", + &metering_root, + &version_manifest, + &version_expected, + ), + "rotationSplit lineage/key", + ); + + let (temporary, key_manifest, key_expected) = copied_contract_with_evidence_replacements( + "metering", + "rotation-boundary", + "metering-rotation-boundary-report-current", + "rotation-key-mismatch", + &[("RuleId=RULE-025", "RuleId=RULE-999")], + ); + collect_contract_rejection( + &mut failures, + "different exact keys form one rotation split", + validate_contract( + "metering", + "rotation-boundary", + &temporary.root, + &key_manifest, + &key_expected, + ), + "rotationSplit lineage/key", + ); + + let (source_root, mut root_manifest, root_expected) = + load_contract("metering", "rotation-boundary"); + let temporary = TemporaryScenario::copy_from(&source_root, "rotation-root-mismatch"); + let artifact = &mut root_manifest["artifacts"][1]; + let old_relative_path = artifact["relativePath"] + .as_str() + .expect("lo artifact relativePath is a string") + .to_owned(); + let new_relative_path = "evidence/client-metering/root-b/lo/SWMTRReportGen.log.lo".to_owned(); + let new_full_path = temporary.root.join(&new_relative_path); + std::fs::create_dir_all( + new_full_path + .parent() + .expect("root-mismatch destination has a parent"), + ) + .expect("root-mismatch destination can be created"); + std::fs::rename(temporary.root.join(old_relative_path), &new_full_path) + .expect("lo evidence can move to a distinct synthetic root"); + artifact["sanitizedSourcePath"] = json!("SYNTHETIC://root-b/CCM/Logs/SWMTRReportGen.log.lo"); + artifact["pathFingerprint"] = json!("synthetic-metering-rotation-boundary-report-lo-root-b"); + artifact["relativePath"] = json!(new_relative_path); + collect_contract_rejection( + &mut failures, + "different synthetic roots form one rotation split", + validate_contract( + "metering", + "rotation-boundary", + &temporary.root, + &root_manifest, + &root_expected, + ), + "rotationSplit lineage/key", + ); + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn review_blocker_unknown_profile_observation_is_unique_per_artifact() { + let (scenario_root, mut manifest, mut expected) = load_contract("inventory", "coverage-states"); + let artifact_id = "inventory-coverage-states-access-denied"; + let artifact = manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == artifact_id) + .expect("accessDenied artifact exists"); + artifact["sourceVersion"] = json!("9.99.UNKNOWN"); + expected["extractionProfile"]["selectionState"] = json!("mixedKnownAndUnknown"); + for suffix in ["a", "b"] { + expected["sourceLocalObservations"] + .as_array_mut() + .expect("sourceLocalObservations are an array") + .push(json!({ + "observationId": format!( + "inventory-coverage-unknown-profile-{suffix}" + ), + "kind": "unknownProfile", + "artifactIds": [artifact_id], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Unknown source version has no selected extraction profile." + })); + } + + assert_rejected_with( + "two canonical unknownProfile observations cite one artifact", + "inventory", + "coverage-states", + &scenario_root, + &manifest, + &expected, + "duplicate unknown-profile observation", + ); +} + +#[test] +fn review_blocker_evidence_line_identity_is_unique_and_nonoverlapping() { + let (scenario_root, manifest, expected) = load_contract("inventory", "recovery-contradictory"); + + let mut overlapping = expected.clone(); + overlapping["transactions"][0]["evidence"][0]["endLine"] = json!(2); + assert_rejected_with( + "recovery cites ranges 1-2 and 2-2", + "inventory", + "recovery-contradictory", + &scenario_root, + &manifest, + &overlapping, + "overlapping evidence line", + ); + + let mut duplicated = expected; + duplicated["transactions"][0]["evidence"][1] = + duplicated["transactions"][0]["evidence"][0].clone(); + assert_rejected( + "recovery duplicates the same physical evidence range", + "inventory", + "recovery-contradictory", + &scenario_root, + &manifest, + &duplicated, + ); +} + +#[test] +fn review_blocker_windows_evidence_paths_use_manifest_separators() { + assert_eq!( + normalize_manifest_relative_path( + r"evidence\client-inventory\root-a\current\InventoryAgent.log" + ), + "evidence/client-inventory/root-a/current/InventoryAgent.log", + "actual evidence files must compare to manifest relativePath on Windows" + ); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs new file mode 100644 index 000000000..9485670da --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -0,0 +1,3634 @@ +use chrono::{DateTime, SecondsFormat, Utc}; +use cmtraceopen_parser::sccm::{ + normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmRole, SccmRotation, + SccmTimeOrderingState, +}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const SCENARIOS: [&str; 14] = [ + "co-management-intune-owned", + "co-management-sccm-owned", + "co-management-transitioning", + "co-management-unknown", + "mixed-unrelated", + "notification-deferred", + "notification-failure", + "notification-received", + "script-failure", + "script-incomplete", + "script-intune-handoff", + "script-success", + "software-center-insufficient", + "software-center-observed", +]; + +const DOCUMENTED_CORPUS_DIGEST: &str = "409619f730304018"; + +const PROHIBITED_CLAIMS: [&str; 4] = [ + "time alone proves causality", + "Intune handoff is an Intune failure", + "unsupported Software Center source is parsed", + "missing coverage proves success or failure", +]; + +#[derive(Debug, PartialEq, Eq)] +struct CorpusInventory { + scenarios: usize, + artifacts: usize, + evidence_files: usize, + evidence_bytes: u64, + capture_states: BTreeMap, + digest: String, +} + +#[derive(Debug)] +struct EvidenceRecord { + fields: BTreeMap, + timestamp: Option, + ordering_state: SccmTimeOrderingState, + source_version: String, +} + +static TEMP_SCENARIO_COUNTER: AtomicU64 = AtomicU64::new(0); + +struct TemporaryScenario { + root: PathBuf, +} + +impl Drop for TemporaryScenario { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn management_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/management") +} + +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 load_contract(scenario: &str) -> (PathBuf, Value, Value) { + let scenario_root = management_root().join(scenario); + ( + scenario_root.clone(), + load_json(&scenario_root.join("manifest.json")), + load_json(&scenario_root.join("expected.json")), + ) +} + +fn scenario_names() -> Vec { + let mut names = std::fs::read_dir(management_root()) + .expect("management fixture root exists") + .map(|entry| entry.expect("management fixture entry is readable").path()) + .filter(|path| path.is_dir()) + .map(|path| { + path.file_name() + .expect("scenario directory has a name") + .to_string_lossy() + .into_owned() + }) + .collect::>(); + names.sort(); + names +} + +fn walk_files(root: &Path) -> Result, String> { + if !root.exists() { + return Ok(Vec::new()); + } + let mut pending = vec![root.to_path_buf()]; + let mut files = Vec::new(); + while let Some(path) = pending.pop() { + if path.is_dir() { + let mut children = std::fs::read_dir(&path) + .map_err(|error| format!("{} is readable: {error}", path.display()))? + .map(|entry| { + entry + .map(|entry| entry.path()) + .map_err(|error| error.to_string()) + }) + .collect::, _>>()?; + children.sort(); + pending.extend(children.into_iter().rev()); + } else { + files.push(path); + } + } + Ok(files) +} + +fn copy_scenario_to_temporary_root(scenario: &str, mutation: &str) -> TemporaryScenario { + let counter = TEMP_SCENARIO_COUNTER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "cmtraceopen-sccm-326-{}-{mutation}-{counter}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temporary scenario root is created"); + let source_root = management_root().join(scenario); + let mut pending = vec![source_root.clone()]; + while let Some(source) = pending.pop() { + let relative = source + .strip_prefix(&source_root) + .expect("source remains below scenario root"); + let destination = root.join(relative); + if source.is_dir() { + std::fs::create_dir_all(&destination).expect("temporary scenario directory is created"); + let mut children = std::fs::read_dir(&source) + .expect("source scenario is readable") + .map(|entry| entry.expect("source entry is readable").path()) + .collect::>(); + children.sort(); + pending.extend(children.into_iter().rev()); + } else { + std::fs::copy(&source, &destination).expect("scenario file is copied"); + } + } + TemporaryScenario { root } +} + +fn required_string<'a>(value: &'a Value, field: &str, context: &str) -> Result<&'a str, String> { + value[field] + .as_str() + .ok_or_else(|| format!("{context} {field} is not a string")) +} + +fn require_exact_object_fields( + value: &Value, + expected_fields: &[&str], + context: &str, +) -> Result<(), String> { + let actual_fields = value + .as_object() + .ok_or_else(|| format!("{context} is not an object"))? + .keys() + .map(String::as_str) + .collect::>(); + let expected_fields = expected_fields.iter().copied().collect::>(); + if actual_fields != expected_fields { + return Err(format!( + "{context} fields are not closed: {actual_fields:?} != {expected_fields:?}" + )); + } + Ok(()) +} + +fn captured_utc_millis(artifact: &Value, context: &str) -> Result { + let raw = required_string(artifact, "capturedUtc", context)?; + let parsed = DateTime::parse_from_rfc3339(raw) + .map_err(|error| format!("{context} capturedUtc is invalid: {error}"))? + .with_timezone(&Utc); + if parsed.to_rfc3339_opts(SecondsFormat::Secs, true) != raw { + return Err(format!("{context} capturedUtc is not canonical UTC")); + } + Ok(parsed.timestamp_millis()) +} + +fn expected_profile(workflow: &str) -> Result<&'static str, String> { + match workflow { + "coManagement" => Ok("sccm-client-co-management-5.00.test-v1"), + "scripts" => Ok("sccm-client-scripts-5.00.test-v1"), + "notification" => Ok("sccm-client-notification-5.00.test-v1"), + "softwareCenter" => Ok("sccm-client-software-center-candidate-v1"), + "mixed" => Ok("sccm-client-management-mixed-test-v1"), + other => Err(format!("unsupported workflow {other}")), + } +} + +fn workflow_logical_artifacts(workflow: &str) -> Result<&'static [&'static str], String> { + match workflow { + "coManagement" => Ok(&["client-co-management"]), + "scripts" => Ok(&["client-co-management", "client-scripts"]), + "notification" => Ok(&["client-co-management", "client-notification"]), + "softwareCenter" => Ok(&["client-co-management", "client-software-center"]), + "mixed" => Ok(&[ + "client-co-management", + "client-notification", + "client-scripts", + ]), + other => Err(format!("unsupported workflow {other}")), + } +} + +fn expected_workload(workflow: &str) -> Result<&'static str, String> { + match workflow { + "coManagement" | "scripts" | "mixed" => Ok("Scripts"), + "notification" => Ok("ClientNotification"), + "softwareCenter" => Ok("SoftwareCenter"), + other => Err(format!("unsupported workflow {other}")), + } +} + +fn expected_transaction_count(scenario: &str) -> usize { + match scenario { + "notification-deferred" + | "notification-failure" + | "notification-received" + | "script-failure" + | "script-success" => 1, + _ => 0, + } +} + +fn source_contract( + logical_artifact: &str, + source_name: &str, +) -> Result<(&'static str, bool), String> { + match (logical_artifact, source_name) { + ("client-co-management", "CoManagementHandler.log") + | ("client-scripts", "Scripts.log") + | ("client-scripts", "Scripts.lo_") + | ("client-notification", "CcmNotificationAgent.log") => Ok(("admitted", true)), + ("client-software-center", "SCClient_SYNTHETIC_1.log") + | ("client-software-center", "SCClient_SYNTHETIC_2.log") + | ("client-software-center", "SCNotify_SYNTHETIC_1.log") => { + Ok(("candidateUnsupported", false)) + } + _ => Err(format!( + "{logical_artifact} does not admit exact source {source_name}" + )), + } +} + +fn validate_relative_path( + relative_path: &str, + logical_artifact: &str, + source_name: &str, + rotation_kind: &str, +) -> Result<(), String> { + let path = Path::new(relative_path); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!("unsafe relative evidence path {relative_path}")); + } + let components = path + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect::>(); + if components.first().map(String::as_str) != Some("evidence") + || components.get(1).map(String::as_str) != Some(logical_artifact) + || components.last().map(String::as_str) != Some(source_name) + || !components + .iter() + .any(|component| component == rotation_kind) + { + return Err(format!( + "evidence path {relative_path} does not bind source/rotation provenance" + )); + } + Ok(()) +} + +fn validate_source_path( + scenario: &str, + logical_artifact: &str, + source_name: &str, + sanitized_source_path: &str, +) -> Result<(), String> { + let required_prefix = format!("SYNTHETIC://client/management/{scenario}/{logical_artifact}/"); + let suffix = sanitized_source_path + .strip_prefix(&required_prefix) + .unwrap_or_default(); + let lower_suffix = suffix.to_ascii_lowercase(); + let components = suffix.split('/').collect::>(); + let shape_is_exact = match components.as_slice() { + [basename] => *basename == source_name, + [segment, basename] if *basename == source_name => matches!( + (scenario, logical_artifact, *segment), + ( + "mixed-unrelated", + "client-notification", + "access" | "current" + ) | ("mixed-unrelated", "client-scripts", "root-a" | "root-b") + ), + _ => false, + }; + if !sanitized_source_path.starts_with(&required_prefix) + || !sanitized_source_path.ends_with(source_name) + || sanitized_source_path.contains(['\\', '\n', '\r']) + || !shape_is_exact + || suffix.split('/').any(|component| { + component.is_empty() + || matches!(component, "." | "..") + || !component + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) + || lower_suffix.contains("%2e") + || suffix.contains(['?', '#']) + { + return Err(format!( + "source path {sanitized_source_path} is not bounded synthetic provenance" + )); + } + Ok(()) +} + +fn path_fingerprint_is_safe(value: &str) -> bool { + value.strip_prefix("safe:path:326:").is_some_and(|suffix| { + !suffix.is_empty() + && suffix.split('-').all(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) + }) +} + +fn source_version_matches_selected_profile(value: &str) -> bool { + value + .strip_prefix("5.00.TEST.") + .is_some_and(|suffix| suffix.len() == 4 && suffix.bytes().all(|byte| byte.is_ascii_digit())) +} + +/// Whitespace tokens with their edge punctuation trimmed. Free-text identity +/// rules run per token so that ordinary sentence punctuation cannot extend a +/// token and hide the shape being screened for. +fn public_free_text_tokens(value: &str) -> impl Iterator { + value.split_ascii_whitespace().map(|token| { + token.trim_matches(|character: char| { + matches!( + character, + '.' | ',' | ';' | ':' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '"' | '\'' + ) + }) + }) +} + +/// True when a single token carries `s-1-` followed by at least two numeric +/// subauthority segments. Applied per token on free-text surfaces and to the +/// whole value on the identifier surface, which is one token by construction. +fn contains_sid_shaped_run(value: &str) -> bool { + value.match_indices("s-1-").any(|(index, _)| { + let mut numeric_segments = 0usize; + for segment in value[index + 4..].split('-') { + if segment.is_empty() || !segment.bytes().all(|byte| byte.is_ascii_digit()) { + break; + } + numeric_segments += 1; + } + numeric_segments >= 2 + }) +} + +fn public_identifier_is_safe(value: &str) -> bool { + !value.is_empty() + && value.len() <= 96 + && value.as_bytes()[0].is_ascii_lowercase() + && !value.starts_with("s-1-5-") + && !contains_sid_shaped_run(value) + && value.split('-').all(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) +} + +fn public_free_text_is_safe(value: &str) -> bool { + if value.trim() != value + || value.is_empty() + || value.len() > 240 + || value.chars().any(char::is_control) + { + return false; + } + + if !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b' ' | b'.' | b',' | b';' | b'\'' | b'-' | b'(' | b')') + }) { + return false; + } + + let lower = value.to_ascii_lowercase(); + if lower.contains("s-1-5-") || public_free_text_tokens(&lower).any(contains_sid_shaped_run) { + return false; + } + + !public_free_text_tokens(value).any(|token| { + let labels = token.split('.').collect::>(); + if labels.iter().any(|label| label.is_empty()) { + return false; + } + // Two or more dots with non-empty labels is network-identifier shaped + // (dotted quads, multi-label hostnames) regardless of label charset. + if labels.len() >= 3 { + return true; + } + labels.len() == 2 + && labels[1].len() >= 2 + && labels[1].bytes().all(|byte| byte.is_ascii_alphabetic()) + }) +} + +fn evidence_refs_cite_unique_records(references: &[(String, u64, u64)]) -> bool { + let mut cited_records = BTreeSet::new(); + references + .iter() + .all(|(artifact_id, start_line, end_line)| { + (*start_line..=*end_line).all(|line| cited_records.insert((artifact_id.clone(), line))) + }) +} + +fn string_array(value: &Value, context: &str) -> Result, String> { + value + .as_array() + .ok_or_else(|| format!("{context} is not an array"))? + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{context} item is not a string")) + }) + .collect() +} + +fn effective_state(artifact: &Value) -> Result<&'static str, String> { + let capture_state = required_string(artifact, "captureState", "artifact")?; + match capture_state { + "captured" + if artifact["catalogState"] == "candidateUnsupported" + && artifact["parserEligible"] == false => + { + Ok("unsupported") + } + "captured" if artifact["rotation"]["fragmentComplete"] == true => Ok("captured"), + "captured" => Ok("partial"), + "capped" => Ok("capped"), + "parseFailed" => Ok("malformed"), + "absent" => Ok("absent"), + "accessDenied" => Ok("accessDenied"), + "unsupported" => Ok("unsupported"), + other => Err(format!("unsupported capture state {other}")), + } +} + +fn physical_capture(artifact: &Value) -> Result { + Ok(matches!( + required_string(artifact, "captureState", "artifact")?, + "captured" | "capped" | "parseFailed" + )) +} + +fn hex_bytes(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn fnv1a64(bytes: &[u8]) -> String { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +fn corpus_inventory() -> CorpusInventory { + let mut artifacts = 0; + let mut evidence_files = 0; + let mut evidence_bytes = 0; + let mut capture_states = BTreeMap::new(); + let mut digest_rows = Vec::new(); + + for scenario in SCENARIOS { + let (scenario_root, manifest, _) = load_contract(scenario); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + artifacts += 1; + let capture_state = artifact["captureState"] + .as_str() + .expect("captureState is a string"); + *capture_states.entry(capture_state.to_owned()).or_insert(0) += 1; + let Some(relative_path) = artifact["relativePath"].as_str() else { + continue; + }; + let bytes = std::fs::read(scenario_root.join(relative_path)) + .expect("evidence bytes are readable"); + evidence_files += 1; + evidence_bytes += bytes.len() as u64; + digest_rows.push(format!( + "{scenario}\0{}\0{relative_path}\0{}\n", + artifact["artifactId"] + .as_str() + .expect("artifactId is a string"), + hex_bytes(&bytes) + )); + } + } + digest_rows.sort(); + + CorpusInventory { + scenarios: SCENARIOS.len(), + artifacts, + evidence_files, + evidence_bytes, + capture_states, + digest: fnv1a64(digest_rows.concat().as_bytes()), + } +} + +fn evidence_records( + scenario_root: &Path, + artifacts: &BTreeMap, + refs: &Value, +) -> Result, String> { + let refs = refs + .as_array() + .ok_or_else(|| "evidence refs are not an array".to_owned())?; + let mut records = Vec::new(); + for evidence_ref in refs { + require_exact_object_fields( + evidence_ref, + &["artifactId", "endLine", "startLine"], + "evidence ref", + )?; + let artifact_id = required_string(evidence_ref, "artifactId", "evidence ref")?; + let artifact = artifacts + .get(artifact_id) + .ok_or_else(|| format!("evidence ref uses unknown artifact {artifact_id}"))?; + if !physical_capture(artifact)? + || artifact["parserEligible"] != true + || effective_state(artifact)? != "captured" + { + return Err(format!( + "evidence ref {artifact_id} does not cite a complete parser-eligible artifact" + )); + } + let relative_path = required_string(artifact, "relativePath", artifact_id)?; + let contents = std::fs::read_to_string(scenario_root.join(relative_path)) + .map_err(|error| format!("{artifact_id} evidence is readable: {error}"))?; + let lines = contents.lines().collect::>(); + let start = evidence_ref["startLine"] + .as_u64() + .ok_or_else(|| format!("{artifact_id} startLine is not an integer"))? + as usize; + let end = evidence_ref["endLine"] + .as_u64() + .ok_or_else(|| format!("{artifact_id} endLine is not an integer"))? + as usize; + if start == 0 || end < start || end > lines.len() { + return Err(format!( + "{artifact_id} evidence line range {start}..={end} is invalid" + )); + } + for line in &lines[start - 1..end] { + records.push(normalized_record(artifact, line)?); + } + } + Ok(records) +} + +fn all_artifact_records( + scenario_root: &Path, + artifact: &Value, +) -> Result, String> { + if !physical_capture(artifact)? + || artifact["parserEligible"] != true + || effective_state(artifact)? != "captured" + { + return Ok(Vec::new()); + } + let artifact_id = required_string(artifact, "artifactId", "artifact")?; + let relative_path = required_string(artifact, "relativePath", artifact_id)?; + let contents = std::fs::read_to_string(scenario_root.join(relative_path)) + .map_err(|error| format!("{artifact_id} evidence is readable: {error}"))?; + let mut records = Vec::new(); + for line in contents.lines() { + records.push(normalized_record(artifact, line)?); + } + Ok(records) +} + +fn normalized_record(artifact: &Value, line: &str) -> Result { + let artifact_id = required_string(artifact, "artifactId", "artifact")?; + if !line.starts_with("").count() != 1 + || !line.contains("]LOG]!> SccmRotation::Current, + "lo" => SccmRotation::LoUnderscore, + other => return Err(format!("{artifact_id} has unsupported rotation {other}")), + }, + coverage: match required_string(artifact, "captureState", artifact_id)? { + "captured" => SccmCoverageState::Captured, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "capped" => SccmCoverageState::Capped, + "parseFailed" => SccmCoverageState::ParseFailed, + "unsupported" => SccmCoverageState::Unsupported, + other => return Err(format!("{artifact_id} has unsupported coverage {other}")), + }, + encoding: artifact["encoding"].as_str().map(str::to_owned), + }; + let evidence = normalize_ccm_artifact(model, line); + if evidence.len() != 1 + || evidence[0].reference.line_start != Some(1) + || evidence[0].reference.line_end != Some(1) + { + return Err(format!( + "{artifact_id} line does not normalize to one logical CCM record" + )); + } + let evidence = &evidence[0]; + let message = evidence + .message + .strip_prefix("[sccm-public-message-v1] ") + .ok_or_else(|| format!("{artifact_id} lacks the versioned public message projection"))? + .to_owned(); + let fields = record_fields(&message, artifact_id)?; + validate_record_field_contract( + required_string(artifact, "logicalArtifactId", artifact_id)?, + &fields, + artifact_id, + )?; + let captured = captured_utc_millis(artifact, artifact_id)?; + if evidence + .timestamp + .utc_millis + .is_some_and(|timestamp| timestamp > captured) + { + return Err(format!("{artifact_id} record postdates capturedUtc")); + } + + Ok(EvidenceRecord { + fields, + timestamp: evidence.timestamp.utc_millis, + ordering_state: evidence.timestamp.ordering_state.clone(), + source_version: source_version.to_owned(), + }) +} + +fn record_fields(message: &str, context: &str) -> Result, String> { + let mut fields = BTreeMap::new(); + for token in message.split_ascii_whitespace() { + let Some((field, value)) = token.split_once('=') else { + continue; + }; + if field.is_empty() + || value.is_empty() + || !field + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(format!( + "{context} contains malformed structured token {token}" + )); + } + if fields.insert(field.to_owned(), value.to_owned()).is_some() { + return Err(format!( + "{context} contains duplicate structured field {field}" + )); + } + } + Ok(fields) +} + +fn validate_record_field_contract( + logical_artifact: &str, + fields: &BTreeMap, + context: &str, +) -> Result<(), String> { + let actual = fields.keys().map(String::as_str).collect::>(); + let exact = |required: &[&str], optional: &[&str]| { + let required = required.iter().copied().collect::>(); + let mut allowed = required.clone(); + allowed.extend(optional.iter().copied()); + actual.is_superset(&required) && actual.is_subset(&allowed) + }; + + let valid = match logical_artifact { + "client-co-management" => exact( + &[ + "Disposition", + "Ownership", + "OwnershipEpochId", + "Terminal", + "Workload", + ], + &[], + ), + "client-notification" => exact( + &[ + "ChannelId", + "Disposition", + "NotificationId", + "Phase", + "ResourceHandle", + "Terminal", + ], + &["Signal"], + ), + "client-scripts" if fields.contains_key("ScriptId") => exact( + &[ + "CommandContextHandle", + "Disposition", + "ExecutionId", + "Phase", + "ResourceHandle", + "ScriptId", + "Terminal", + ], + &["Signal"], + ), + "client-scripts" => { + let allowed = BTreeSet::from([ + "Disposition", + "SameMinute", + "Signal", + "Terminal", + "UnkeyedCandidate", + "UnrelatedServiceError", + ]); + !actual.is_empty() + && actual.is_subset(&allowed) + && (actual.contains("UnkeyedCandidate") || actual.contains("UnrelatedServiceError")) + } + other => return Err(format!("{context} has unsupported record family {other}")), + }; + if !valid { + return Err(format!( + "{context} structured fields are outside the closed {logical_artifact} contract: {actual:?}" + )); + } + Ok(()) +} + +fn key_fields(workflow: &str) -> Result<&'static [&'static str], String> { + match workflow { + "scripts" => Ok(&["ScriptId", "ExecutionId", "ResourceHandle"]), + "notification" => Ok(&["NotificationId", "ChannelId", "ResourceHandle"]), + other => Err(format!( + "{other} does not have operational transaction keys" + )), + } +} + +fn allowed_phases(workflow: &str) -> Result<&'static [&'static str], String> { + match workflow { + "scripts" => Ok(&["Receive", "Execute", "Report"]), + "notification" => Ok(&["Receive", "DeferOrDispatch", "Acknowledge"]), + other => Err(format!("{other} does not have operational phases")), + } +} + +fn phase_rank(workflow: &str, record: &EvidenceRecord) -> Result { + let phase = record + .fields + .get("Phase") + .ok_or_else(|| "cited operational record does not have one exact phase".to_owned())?; + allowed_phases(workflow)? + .iter() + .position(|candidate| *candidate == phase) + .ok_or_else(|| format!("cited operational record has unknown phase {phase}")) +} + +fn validate_temporal_progression( + workflow: &str, + transaction_id: &str, + asserted_phase: &str, + records: &[EvidenceRecord], + latest_ownership_timestamp: i64, +) -> Result<(), String> { + let asserted_rank = allowed_phases(workflow)? + .iter() + .position(|phase| *phase == asserted_phase) + .ok_or_else(|| format!("{transaction_id} has unknown asserted phase {asserted_phase}"))?; + let mut phase_bounds = BTreeMap::::new(); + for record in records { + let timestamp = record + .timestamp + .ok_or_else(|| format!("{transaction_id} cites a record without a usable timestamp"))?; + let rank = + phase_rank(workflow, record).map_err(|error| format!("{transaction_id}: {error}"))?; + phase_bounds + .entry(rank) + .and_modify(|(minimum, maximum)| { + *minimum = (*minimum).min(timestamp); + *maximum = (*maximum).max(timestamp); + }) + .or_insert((timestamp, timestamp)); + } + + let required_ranks = match (workflow, asserted_phase) { + ("notification", "Acknowledge") => vec![0, 2], + _ => (0..=asserted_rank).collect::>(), + }; + if phase_bounds.keys().copied().ne(required_ranks) { + return Err(format!( + "{transaction_id} cited phases do not contain the required workflow progression" + )); + } + + let earliest_operational = phase_bounds + .values() + .map(|(minimum, _)| *minimum) + .min() + .expect("nonempty transaction evidence was checked"); + if latest_ownership_timestamp >= earliest_operational { + return Err(format!( + "{transaction_id} ownership is late or temporally ambiguous" + )); + } + + let mut previous_maximum = None; + for (minimum, maximum) in phase_bounds.values() { + if previous_maximum.is_some_and(|previous| previous >= *minimum) { + return Err(format!( + "{transaction_id} cited phase timestamps are reversed or ambiguous" + )); + } + previous_maximum = Some(*maximum); + } + + Ok(()) +} + +fn validate_contract( + scenario: &str, + scenario_root: &Path, + manifest: &Value, + expected: &Value, +) -> Result<(), String> { + require_exact_object_fields( + manifest, + &[ + "artifacts", + "bundle", + "contractState", + "proposalOnly", + "sccmManifestVersion", + "scenario", + "syntheticFixture", + "workflowFamily", + ], + "manifest", + )?; + require_exact_object_fields( + &manifest["bundle"], + &["bundleId", "captureHost", "role", "siteCode"], + "manifest bundle", + )?; + require_exact_object_fields( + expected, + &[ + "contractState", + "coverage", + "extractionProfile", + "findings", + "ownership", + "prohibitedClaims", + "scenario", + "sourceLocalObservations", + "transactions", + "workflow", + ], + "expected contract", + )?; + if manifest["sccmManifestVersion"] != 1 + || manifest["contractState"] != "proposedPending318And319" + || manifest["proposalOnly"] != true + || manifest["syntheticFixture"] != true + || manifest["scenario"] != scenario + { + return Err("manifest identity/proposal contract is invalid".to_owned()); + } + let workflow = required_string(manifest, "workflowFamily", "manifest")?; + if expected["contractState"] != "proposedPending318And319" + || expected["scenario"] != scenario + || expected["workflow"] != workflow + { + return Err("expected identity/workflow contract is invalid".to_owned()); + } + if manifest["bundle"]["bundleId"] != format!("sccm-326-{scenario}") + || manifest["bundle"]["captureHost"] != "LAB-CLIENT-01" + || manifest["bundle"]["role"] != "client" + || manifest["bundle"]["siteCode"] != "LAB" + { + return Err("bundle identity/role is not the bounded synthetic client".to_owned()); + } + + let artifacts = manifest["artifacts"] + .as_array() + .ok_or_else(|| "manifest artifacts are not an array".to_owned())?; + if artifacts.is_empty() { + return Err("scenario has no artifacts".to_owned()); + } + let mut artifacts_by_id = BTreeMap::new(); + let mut artifact_order = Vec::new(); + let mut referenced_files = BTreeSet::new(); + let mut relative_paths = BTreeSet::new(); + let mut path_fingerprints = BTreeSet::new(); + let mut physical_source_identities = BTreeSet::new(); + let mut expected_coverage = BTreeMap::new(); + let mut unknown_version_artifacts = BTreeSet::new(); + let mut invalid_offset_artifacts = BTreeSet::new(); + + for artifact in artifacts { + let artifact_id = required_string(artifact, "artifactId", "artifact")?; + require_exact_object_fields( + artifact, + &[ + "artifactId", + "capturedUtc", + "captureState", + "catalogState", + "collectionLimit", + "encoding", + "logicalArtifactId", + "parserEligible", + "pathFingerprint", + "relativePath", + "role", + "rotation", + "sanitizedSourcePath", + "sourceName", + "sourceVersion", + ], + artifact_id, + )?; + require_exact_object_fields( + &artifact["rotation"], + &["fragmentComplete", "kind"], + &format!("{artifact_id} rotation"), + )?; + require_exact_object_fields( + &artifact["collectionLimit"], + &["capped", "limitBytes"], + &format!("{artifact_id} collectionLimit"), + )?; + captured_utc_millis(artifact, artifact_id)?; + artifact_order.push(artifact_id.to_owned()); + if artifacts_by_id + .insert(artifact_id.to_owned(), artifact) + .is_some() + { + return Err(format!("duplicate artifactId {artifact_id}")); + } + if artifact["role"] != "client" { + return Err(format!("{artifact_id} does not preserve client role")); + } + let logical_artifact = required_string(artifact, "logicalArtifactId", artifact_id)?; + if !workflow_logical_artifacts(workflow)?.contains(&logical_artifact) { + return Err(format!( + "{artifact_id} crosses the {workflow} logical source boundary" + )); + } + let source_name = required_string(artifact, "sourceName", artifact_id)?; + let (required_catalog_state, required_parser_eligibility) = + source_contract(logical_artifact, source_name)?; + if artifact["catalogState"] != required_catalog_state + || artifact["parserEligible"] != required_parser_eligibility + { + return Err(format!( + "{artifact_id} source capability does not match its exact catalog contract" + )); + } + let rotation_kind = required_string(&artifact["rotation"], "kind", artifact_id)?; + match rotation_kind { + "current" if source_name.ends_with(".lo_") => { + return Err(format!( + "{artifact_id} current artifact uses an archive suffix" + )); + } + "lo" if !source_name.ends_with(".lo_") => { + return Err(format!("{artifact_id} archived artifact lacks .lo_ suffix")); + } + "current" | "lo" => {} + other => { + return Err(format!( + "{artifact_id} uses unsupported rotation kind {other}" + )); + } + } + expected_coverage.insert( + artifact_id.to_owned(), + ( + logical_artifact.to_owned(), + effective_state(artifact)?.to_owned(), + ), + ); + let physical = physical_capture(artifact)?; + let relative_path = artifact["relativePath"].as_str(); + if physical != relative_path.is_some() { + return Err(format!( + "{artifact_id} physical capture does not match relativePath" + )); + } + if let Some(relative_path) = relative_path { + validate_relative_path(relative_path, logical_artifact, source_name, rotation_kind)?; + if !relative_paths.insert(relative_path.to_owned()) { + return Err(format!("duplicate physical evidence path {relative_path}")); + } + let sanitized_source_path = + required_string(artifact, "sanitizedSourcePath", artifact_id)?; + validate_source_path( + scenario, + logical_artifact, + source_name, + sanitized_source_path, + )?; + if !physical_source_identities.insert(sanitized_source_path.to_ascii_lowercase()) { + return Err(format!( + "{artifact_id} collides with a sanitized physical source identity" + )); + } + let path_fingerprint = required_string(artifact, "pathFingerprint", artifact_id)?; + if !path_fingerprint_is_safe(path_fingerprint) + || !path_fingerprints.insert(path_fingerprint.to_ascii_lowercase()) + { + return Err(format!( + "{artifact_id} has blank, unsafe, or colliding path provenance" + )); + } + let source_version = required_string(artifact, "sourceVersion", artifact_id)?; + if required_parser_eligibility + && !source_version_matches_selected_profile(source_version) + { + unknown_version_artifacts.insert(artifact_id.to_owned()); + } + let path = scenario_root.join(relative_path); + let bytes = std::fs::read(&path) + .map_err(|error| format!("{} is readable: {error}", path.display()))?; + if std::str::from_utf8(&bytes).is_err() || artifact["encoding"] != "utf-8" { + return Err(format!("{artifact_id} is not declared and encoded UTF-8")); + } + if !String::from_utf8_lossy(&bytes).contains("SYNTHETIC FIXTURE") { + return Err(format!("{artifact_id} lacks the synthetic marker")); + } + referenced_files.insert(relative_path.to_owned()); + } else { + let capture_state = required_string(artifact, "captureState", artifact_id)?; + if artifact["encoding"].is_string() { + return Err(format!( + "{artifact_id} nonphysical artifact invents an encoding" + )); + } + match capture_state { + "absent" => { + if !artifact["sanitizedSourcePath"].is_null() + || !artifact["pathFingerprint"].is_null() + || !artifact["sourceVersion"].is_null() + { + return Err(format!( + "{artifact_id} absent source invents path/version provenance" + )); + } + } + "accessDenied" | "unsupported" => { + let source_path = + required_string(artifact, "sanitizedSourcePath", artifact_id)?; + validate_source_path(scenario, logical_artifact, source_name, source_path)?; + if !physical_source_identities.insert(source_path.to_ascii_lowercase()) { + return Err(format!( + "{artifact_id} collides with a sanitized physical source identity" + )); + } + let path_fingerprint = + required_string(artifact, "pathFingerprint", artifact_id)?; + if !path_fingerprint_is_safe(path_fingerprint) + || !path_fingerprints.insert(path_fingerprint.to_ascii_lowercase()) + { + return Err(format!( + "{artifact_id} attempted path fingerprint is unsafe or colliding" + )); + } + if !artifact["sourceVersion"].is_null() { + return Err(format!( + "{artifact_id} noncapture state invents source version" + )); + } + } + other => { + return Err(format!("{artifact_id} state {other} cannot be nonphysical")); + } + } + } + + let records = all_artifact_records(scenario_root, artifact)?; + if !records.is_empty() + && records + .iter() + .any(|record| record.ordering_state == SccmTimeOrderingState::OffsetInvalid) + { + invalid_offset_artifacts.insert(artifact_id.to_owned()); + } + let capped = artifact["collectionLimit"]["capped"] + .as_bool() + .ok_or_else(|| format!("{artifact_id} collectionLimit.capped is not a boolean"))?; + let fragment_complete = artifact["rotation"]["fragmentComplete"] + .as_bool() + .ok_or_else(|| format!("{artifact_id} fragmentComplete is not a boolean"))?; + if required_string(artifact, "captureState", artifact_id)? == "capped" { + if !capped + || fragment_complete + || artifact["collectionLimit"]["limitBytes"] + .as_u64() + .is_none_or(|limit| limit == 0) + { + return Err(format!( + "{artifact_id} capped state lacks explicit cap/partial provenance" + )); + } + } else if capped || !artifact["collectionLimit"]["limitBytes"].is_null() { + return Err(format!( + "{artifact_id} noncapped state invents collection-limit provenance" + )); + } + } + let mut sorted_artifact_order = artifact_order.clone(); + sorted_artifact_order.sort(); + if artifact_order != sorted_artifact_order { + return Err("manifest artifacts are not deterministically sorted".to_owned()); + } + + let actual_files = walk_files(&scenario_root.join("evidence"))? + .into_iter() + .map(|path| { + path.strip_prefix(scenario_root) + .expect("walk root is below scenario") + .to_string_lossy() + .into_owned() + }) + .collect::>(); + if actual_files != referenced_files { + return Err(format!( + "manifest evidence projection differs: {actual_files:?} != {referenced_files:?}" + )); + } + + let coverage = expected["coverage"] + .as_array() + .ok_or_else(|| "expected coverage is not an array".to_owned())?; + let mut declared_coverage = BTreeMap::new(); + let mut coverage_order = Vec::new(); + for row in coverage { + let artifact_id = required_string(row, "artifactId", "coverage row")?; + require_exact_object_fields( + row, + &["artifactId", "logicalArtifactId", "state"], + artifact_id, + )?; + coverage_order.push(artifact_id.to_owned()); + if declared_coverage + .insert( + artifact_id.to_owned(), + ( + required_string(row, "logicalArtifactId", artifact_id)?.to_owned(), + required_string(row, "state", artifact_id)?.to_owned(), + ), + ) + .is_some() + { + return Err(format!("duplicate coverage row {artifact_id}")); + } + } + let mut sorted_coverage_order = coverage_order.clone(); + sorted_coverage_order.sort(); + if coverage_order != sorted_coverage_order { + return Err("coverage rows are not deterministically sorted".to_owned()); + } + if declared_coverage != expected_coverage { + return Err(format!( + "coverage is not an exact manifest projection: {declared_coverage:?} != {expected_coverage:?}" + )); + } + + let required_profile_selection = match workflow { + "softwareCenter" => "unsupportedCandidate", + "mixed" + if !unknown_version_artifacts.is_empty() && !invalid_offset_artifacts.is_empty() => + { + "mixedUnknownAndInvalid" + } + _ if !unknown_version_artifacts.is_empty() => "unknownProfile", + _ => "selected", + }; + let profile = &expected["extractionProfile"]; + require_exact_object_fields( + profile, + &["id", "selectionState", "versionPrefix"], + "extractionProfile", + )?; + if profile["id"] != expected_profile(workflow)? + || profile["versionPrefix"] != "5.00.TEST." + || profile["selectionState"] != required_profile_selection + { + return Err("extraction profile identity/selection is invalid".to_owned()); + } + + if expected["findings"] + .as_array() + .is_none_or(|findings| !findings.is_empty()) + { + return Err("preparation corpus must not ship production findings".to_owned()); + } + let prohibited_claims = expected["prohibitedClaims"] + .as_array() + .ok_or_else(|| "prohibitedClaims is not an array".to_owned())?; + if prohibited_claims + .iter() + .map(|value| value.as_str().unwrap_or_default()) + .ne(PROHIBITED_CLAIMS) + { + return Err("prohibitedClaims safety boundary is not exact".to_owned()); + } + + let ownership = &expected["ownership"]; + require_exact_object_fields( + ownership, + &[ + "classification", + "confidence", + "coverageGapArtifactIds", + "evidence", + "terminalHandoff", + "workload", + ], + "ownership", + )?; + let ownership_class = required_string(ownership, "classification", "ownership")?; + let ownership_confidence = required_string(ownership, "confidence", "ownership")?; + if ownership["workload"] != expected_workload(workflow)? { + return Err(format!( + "ownership workload does not match the exact {workflow} workflow" + )); + } + if !matches!( + ownership_class, + "SccmOwned" | "IntuneOwned" | "SharedOrTransitioning" | "UnknownOwnership" + ) { + return Err(format!( + "unsupported ownership classification {ownership_class}" + )); + } + match ownership_class { + "SccmOwned" | "IntuneOwned" if ownership_confidence != "high" => { + return Err("terminal ownership classification is not high confidence".to_owned()); + } + "SharedOrTransitioning" if ownership_confidence != "medium" => { + return Err("transitioning ownership is not medium confidence".to_owned()); + } + "UnknownOwnership" if ownership_confidence != "low" => { + return Err("unknown ownership is not low confidence".to_owned()); + } + _ => {} + } + let ownership_records = + evidence_records(scenario_root, &artifacts_by_id, &ownership["evidence"])?; + let ownership_evidence = ownership["evidence"] + .as_array() + .ok_or_else(|| "ownership evidence is not an array".to_owned())?; + let mut ownership_ref_order = Vec::new(); + for evidence_ref in ownership_evidence { + let artifact_id = required_string(evidence_ref, "artifactId", "ownership evidence")?; + ownership_ref_order.push(( + artifact_id.to_owned(), + evidence_ref["startLine"].as_u64().unwrap_or(0), + evidence_ref["endLine"].as_u64().unwrap_or(0), + )); + let artifact = artifacts_by_id + .get(artifact_id) + .ok_or_else(|| format!("ownership cites unknown artifact {artifact_id}"))?; + if artifact["logicalArtifactId"] != "client-co-management" { + return Err(format!( + "ownership borrows non-co-management evidence {artifact_id}" + )); + } + } + let mut sorted_ownership_refs = ownership_ref_order.clone(); + sorted_ownership_refs.sort(); + if ownership_ref_order != sorted_ownership_refs + || sorted_ownership_refs + .windows(2) + .any(|references| references[0] == references[1]) + { + return Err("ownership evidence is duplicated or not deterministically sorted".to_owned()); + } + if !evidence_refs_cite_unique_records(&ownership_ref_order) { + return Err( + "ownership evidence ranges overlap and double-count a logical record".to_owned(), + ); + } + if ownership_class != "UnknownOwnership" { + let workload = required_string(ownership, "workload", "ownership")?; + if ownership_records.is_empty() + || ownership_records.iter().any(|record| { + record.fields.get("Workload").map(String::as_str) != Some(workload) + || record.fields.get("Ownership").map(String::as_str) != Some(ownership_class) + }) + { + return Err("ownership classification is not bound to cited evidence".to_owned()); + } + if ownership_records.iter().any(|record| { + record.ordering_state != SccmTimeOrderingState::NormalizedUtc + || record.timestamp.is_none() + || !source_version_matches_selected_profile(record.source_version.as_str()) + }) { + return Err( + "ownership classification lacks usable timestamp/profile provenance".to_owned(), + ); + } + match ownership_class { + "SccmOwned" + if ownership_records.iter().any(|record| { + record.fields.get("Disposition").map(String::as_str) != Some("Owned") + || record.fields.get("Terminal").map(String::as_str) != Some("true") + }) => + { + return Err("SCCM ownership lacks terminal owned evidence".to_owned()); + } + "IntuneOwned" + if ownership_records.iter().any(|record| { + record.fields.get("Disposition").map(String::as_str) != Some("Handoff") + || record.fields.get("Terminal").map(String::as_str) != Some("true") + }) => + { + return Err("Intune ownership lacks terminal handoff evidence".to_owned()); + } + "SharedOrTransitioning" + if ownership_records.iter().any(|record| { + record.fields.get("Disposition").map(String::as_str) != Some("Transitioning") + || record.fields.get("Terminal").map(String::as_str) != Some("false") + }) => + { + return Err("transitioning ownership lacks nonterminal evidence".to_owned()); + } + _ => {} + } + } else if ownership_records.is_empty() { + if ownership["coverageGapArtifactIds"] + .as_array() + .is_none_or(Vec::is_empty) + { + return Err("unknown ownership has neither evidence nor a coverage gap".to_owned()); + } + } else if !ownership_records + .iter() + .any(|record| record.fields.get("Ownership").map(String::as_str) == Some("SccmOwned")) + || !ownership_records + .iter() + .any(|record| record.fields.get("Ownership").map(String::as_str) == Some("IntuneOwned")) + { + return Err("cited unknown ownership is not an explicit contradiction".to_owned()); + } + if (ownership_class == "IntuneOwned") != (ownership["terminalHandoff"] == true) { + return Err("terminal handoff flag does not match ownership classification".to_owned()); + } + let ownership_gap_ids = string_array( + &ownership["coverageGapArtifactIds"], + "ownership coverageGapArtifactIds", + )?; + let mut sorted_ownership_gap_ids = ownership_gap_ids.clone(); + sorted_ownership_gap_ids.sort(); + sorted_ownership_gap_ids.dedup(); + if ownership_gap_ids != sorted_ownership_gap_ids { + return Err("ownership coverage gaps are duplicated or not sorted".to_owned()); + } + for artifact_id in ownership_gap_ids { + let artifact = artifacts_by_id + .get(&artifact_id) + .ok_or_else(|| format!("ownership gap cites unknown {artifact_id}"))?; + if artifact["logicalArtifactId"] != "client-co-management" + || effective_state(artifact)? == "captured" + { + return Err(format!( + "ownership gap {artifact_id} is not a bounded noncomplete co-management source" + )); + } + } + + let transactions = expected["transactions"] + .as_array() + .ok_or_else(|| "transactions are not an array".to_owned())?; + if transactions.len() != expected_transaction_count(scenario) { + return Err(format!( + "{scenario} transaction cardinality is not the exact scenario contract" + )); + } + if ownership_class != "SccmOwned" && !transactions.is_empty() { + return Err("operational transactions require evidenced SCCM ownership".to_owned()); + } + if matches!(workflow, "coManagement" | "softwareCenter" | "mixed") && !transactions.is_empty() { + return Err(format!("{workflow} cannot ship operational transactions")); + } + let latest_ownership_timestamp = ownership_records + .iter() + .filter_map(|record| record.timestamp) + .max(); + let mut transaction_ids = BTreeSet::new(); + let mut transaction_keys = BTreeSet::new(); + let mut transaction_order = Vec::new(); + for transaction in transactions { + let transaction_id = required_string(transaction, "transactionId", "transaction")?; + if !public_identifier_is_safe(transaction_id) { + return Err(format!( + "transaction id {transaction_id} is outside the closed public identifier grammar" + )); + } + require_exact_object_fields( + transaction, + &[ + "classification", + "confidence", + "coverageGapArtifactIds", + "evidence", + "key", + "lastSuccessfulPhase", + "nextArtifact", + "phase", + "state", + "transactionId", + "workflow", + ], + transaction_id, + )?; + transaction_order.push(transaction_id.to_owned()); + if !transaction_ids.insert(transaction_id.to_owned()) { + return Err(format!("duplicate transactionId {transaction_id}")); + } + if transaction["workflow"] != workflow { + return Err(format!("{transaction_id} crosses workflow families")); + } + let key = &transaction["key"]; + if key["keyProfileKind"] + != match workflow { + "scripts" => "scriptExact", + "notification" => "notificationExact", + _ => unreachable!("operational workflows checked above"), + } + || key["extractionProfileId"] != expected_profile(workflow)? + || key["confidence"] != "exact" + { + return Err(format!("{transaction_id} key is not exact and versioned")); + } + let fields = key_fields(workflow)?; + let mut expected_key_fields = fields.iter().copied().collect::>(); + expected_key_fields.extend(["confidence", "extractionProfileId", "keyProfileKind"]); + let actual_key_fields = key + .as_object() + .ok_or_else(|| format!("{transaction_id} key is not an object"))? + .keys() + .map(String::as_str) + .collect::>(); + if actual_key_fields != expected_key_fields { + return Err(format!( + "{transaction_id} key fields are not the exact {workflow} contract" + )); + } + for field in fields { + let value = required_string(key, field, transaction_id)?; + if value.is_empty() + || value.contains(['\n', '\r']) + || (*field == "ResourceHandle" && !value.starts_with("safe:")) + { + return Err(format!("{transaction_id} key {field} is unsafe")); + } + let required_prefix = match *field { + "ScriptId" => "SCRIPT-326-", + "ExecutionId" => "EXEC-326-", + "NotificationId" => "NOTIFY-326-", + "ChannelId" => "CHANNEL-326-", + "ResourceHandle" => "safe:resource-326-", + _ => unreachable!("exact key field table"), + }; + if !value.starts_with(required_prefix) { + return Err(format!( + "{transaction_id} key {field} is outside the synthetic profile" + )); + } + } + let transaction_key = fields + .iter() + .map(|field| required_string(key, field, transaction_id).map(str::to_owned)) + .collect::, _>>()?; + if !transaction_keys.insert(transaction_key) { + return Err(format!( + "{transaction_id} duplicates an exact normalized transaction key" + )); + } + let transaction_evidence = transaction["evidence"] + .as_array() + .ok_or_else(|| format!("{transaction_id} evidence is not an array"))?; + let expected_logical = if workflow == "scripts" { + "client-scripts" + } else { + "client-notification" + }; + let mut transaction_ref_order = Vec::new(); + for evidence_ref in transaction_evidence { + let artifact_id = required_string(evidence_ref, "artifactId", transaction_id)?; + transaction_ref_order.push(( + artifact_id.to_owned(), + evidence_ref["startLine"].as_u64().unwrap_or(0), + evidence_ref["endLine"].as_u64().unwrap_or(0), + )); + let artifact = artifacts_by_id + .get(artifact_id) + .ok_or_else(|| format!("{transaction_id} cites unknown {artifact_id}"))?; + if artifact["logicalArtifactId"] != expected_logical { + return Err(format!( + "{transaction_id} borrows evidence outside {expected_logical}" + )); + } + } + let mut sorted_transaction_refs = transaction_ref_order.clone(); + sorted_transaction_refs.sort(); + if transaction_ref_order != sorted_transaction_refs + || sorted_transaction_refs + .windows(2) + .any(|references| references[0] == references[1]) + { + return Err(format!( + "{transaction_id} evidence references are duplicated or not sorted" + )); + } + let records = evidence_records(scenario_root, &artifacts_by_id, &transaction["evidence"])?; + if !evidence_refs_cite_unique_records(&transaction_ref_order) { + return Err(format!( + "{transaction_id} evidence ranges overlap and double-count a logical record" + )); + } + if records.is_empty() { + return Err(format!("{transaction_id} has no cited evidence")); + } + for record in &records { + for field in fields { + let value = required_string(key, field, transaction_id)?; + if record.fields.get(*field).map(String::as_str) != Some(value) { + return Err(format!( + "{transaction_id} key {field} is not co-located in every cited record" + )); + } + } + } + let phase = required_string(transaction, "phase", transaction_id)?; + if !allowed_phases(workflow)?.contains(&phase) { + return Err(format!("{transaction_id} has unsupported phase {phase}")); + } + let last_successful_phase = transaction["lastSuccessfulPhase"] + .as_str() + .ok_or_else(|| format!("{transaction_id} lastSuccessfulPhase is not a string"))?; + if !allowed_phases(workflow)?.contains(&last_successful_phase) { + return Err(format!( + "{transaction_id} has unsupported last successful phase {last_successful_phase}" + )); + } + let confidence = required_string(transaction, "confidence", transaction_id)?; + if records.iter().any(|record| { + record.ordering_state != SccmTimeOrderingState::NormalizedUtc + || record.timestamp.is_none() + || !source_version_matches_selected_profile(record.source_version.as_str()) + }) { + return Err(format!( + "{transaction_id} lacks usable time/profile provenance" + )); + } + validate_temporal_progression( + workflow, + transaction_id, + phase, + &records, + latest_ownership_timestamp.ok_or_else(|| { + format!("{transaction_id} lacks timestamped SCCM ownership evidence") + })?, + )?; + let classification = required_string(transaction, "classification", transaction_id)?; + let state = required_string(transaction, "state", transaction_id)?; + let has_record = |disposition: &str, terminal: bool| { + records.iter().any(|record| { + record.fields.get("Phase").map(String::as_str) == Some(phase) + && record.fields.get("Disposition").map(String::as_str) == Some(disposition) + && record.fields.get("Terminal").map(String::as_str) + == Some(if terminal { "true" } else { "false" }) + }) + }; + match classification { + "success" => { + let disposition = if workflow == "notification" { + "Acknowledged" + } else { + "Succeeded" + }; + if confidence != "high" + || !matches!(state, "succeeded" | "acknowledged") + || (workflow == "scripts" && phase != "Report") + || (workflow == "notification" && phase != "Acknowledge") + || last_successful_phase != phase + || !has_record(disposition, true) + { + return Err(format!( + "{transaction_id} success lacks cited terminal evidence" + )); + } + } + "confirmedFailure" => { + if confidence != "high" + || state != "failed" + || last_successful_phase == phase + || !has_record("Failed", true) + || !records.iter().any(|record| { + record.fields.get("Phase").map(String::as_str) + == Some(last_successful_phase) + && record.fields.get("Disposition").map(String::as_str) + == Some("Succeeded") + && record.fields.get("Terminal").map(String::as_str) == Some("false") + }) + { + return Err(format!( + "{transaction_id} failure lacks cited terminal evidence" + )); + } + } + "blockedOrDeferred" => { + if workflow != "notification" + || confidence != "medium" + || state != "deferred" + || phase != "DeferOrDispatch" + || last_successful_phase != "Receive" + || !has_record("Deferred", false) + { + return Err(format!( + "{transaction_id} deferred state is not conservative" + )); + } + } + other => { + return Err(format!( + "{transaction_id} has unsupported classification {other}" + )); + } + } + let coverage_gap_ids = string_array( + &transaction["coverageGapArtifactIds"], + &format!("{transaction_id} coverageGapArtifactIds"), + )?; + let mut sorted_gap_ids = coverage_gap_ids.clone(); + sorted_gap_ids.sort(); + sorted_gap_ids.dedup(); + if coverage_gap_ids != sorted_gap_ids { + return Err(format!( + "{transaction_id} coverage gaps are duplicated or not sorted" + )); + } + for artifact_id in coverage_gap_ids { + let artifact = artifacts_by_id + .get(&artifact_id) + .ok_or_else(|| format!("{transaction_id} gap cites unknown {artifact_id}"))?; + if effective_state(artifact)? == "captured" { + return Err(format!( + "{transaction_id} gap cites complete artifact {artifact_id}" + )); + } + } + match classification { + "blockedOrDeferred" => { + let next = transaction["nextArtifact"] + .as_object() + .ok_or_else(|| format!("{transaction_id} lacks a bounded next artifact"))?; + if next.keys().map(String::as_str).collect::>() + != BTreeSet::from(["logicalArtifactId", "reason"]) + || next["logicalArtifactId"] != "client-notification" + { + return Err(format!( + "{transaction_id} next artifact is not the exact notification group" + )); + } + let reason = next["reason"] + .as_str() + .ok_or_else(|| format!("{transaction_id} next reason is not a string"))?; + if !public_free_text_is_safe(reason) { + return Err(format!( + "{transaction_id} next artifact reason leaks identity or path data" + )); + } + let lower_reason = reason.to_ascii_lowercase(); + if reason.trim() != reason + || reason.len() > 240 + || reason.contains(['*', '?', '\\']) + || reason.starts_with('/') + || lower_reason.contains("all files") + || lower_reason.contains("entire disk") + || lower_reason.contains("whole filesystem") + { + return Err(format!( + "{transaction_id} next artifact request is unbounded" + )); + } + } + _ if !transaction["nextArtifact"].is_null() => { + return Err(format!( + "{transaction_id} terminal result invents a next artifact" + )); + } + _ => {} + } + } + let mut sorted_transaction_order = transaction_order.clone(); + sorted_transaction_order.sort(); + if transaction_order != sorted_transaction_order { + return Err("transactions are not deterministically sorted".to_owned()); + } + + let observations = expected["sourceLocalObservations"] + .as_array() + .ok_or_else(|| "sourceLocalObservations are not an array".to_owned())?; + let mut observed_noncomplete = BTreeSet::new(); + let mut observed_malformed = BTreeSet::new(); + let mut observed_unknown_profiles = BTreeSet::new(); + let mut observed_invalid_offsets = BTreeSet::new(); + let mut observation_ids = BTreeSet::new(); + let mut observation_order = Vec::new(); + for observation in observations { + let observation_id = required_string(observation, "observationId", "observation")?; + if !public_identifier_is_safe(observation_id) { + return Err(format!( + "observation id {observation_id} is outside the closed public identifier grammar" + )); + } + require_exact_object_fields( + observation, + &[ + "artifactIds", + "claim", + "confidenceCeiling", + "correlationEligible", + "kind", + "observationId", + ], + observation_id, + )?; + observation_order.push(observation_id.to_owned()); + if !observation_ids.insert(observation_id.to_owned()) { + return Err(format!("duplicate observationId {observation_id}")); + } + if observation["confidenceCeiling"] != "low" || observation["correlationEligible"] != false + { + return Err(format!("{observation_id} exceeds its source-local ceiling")); + } + let kind = required_string(observation, "kind", observation_id)?; + if !matches!( + kind, + "coverageGap" + | "rotationSplit" + | "unkeyedRecord" + | "unsupportedCandidate" + | "malformedRecord" + | "unknownProfile" + | "invalidOffset" + | "physicalCollision" + ) { + return Err(format!("{observation_id} has unsupported kind {kind}")); + } + let claim = required_string(observation, "claim", observation_id)?; + let lower_claim = claim.to_ascii_lowercase(); + let claim_tokens = lower_claim + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .collect::>(); + if claim.trim() != claim + || claim.is_empty() + || claim_tokens.iter().any(|token| { + matches!( + *token, + "cause" | "caused" | "causes" | "causal" | "causality" + ) + }) + || claim_tokens + .iter() + .any(|token| matches!(*token, "prove" | "proved" | "proves")) + || (claim_tokens.contains("because") + && claim_tokens.iter().any(|token| { + matches!( + *token, + "failed" + | "failure" + | "unavailable" + | "outcome" + | "succeeded" + | "success" + | "broken" + ) + })) + || claim_tokens + .iter() + .any(|token| matches!(*token, "server" | "servers")) + || lower_claim.contains("intune failure") + || lower_claim.contains("resulted in") + || lower_claim.contains("responsible for") + || lower_claim.contains("due to") + || lower_claim.contains("led to") + || lower_claim.contains("root cause") + { + return Err(format!( + "{observation_id} makes an unsupported causal claim" + )); + } + if !public_free_text_is_safe(claim) { + return Err(format!( + "{observation_id} contains unsafe public identity or path data" + )); + } + let artifact_ids = string_array( + &observation["artifactIds"], + &format!("{observation_id} artifactIds"), + )?; + if artifact_ids.is_empty() { + return Err(format!("{observation_id} has no bounded artifact identity")); + } + let mut sorted_artifact_ids = artifact_ids.clone(); + sorted_artifact_ids.sort(); + sorted_artifact_ids.dedup(); + if artifact_ids != sorted_artifact_ids { + return Err(format!( + "{observation_id} artifact IDs are duplicated or unsorted" + )); + } + for artifact_id in &artifact_ids { + let artifact = artifacts_by_id + .get(artifact_id.as_str()) + .ok_or_else(|| format!("{observation_id} cites unknown {artifact_id}"))?; + if effective_state(artifact)? != "captured" { + observed_noncomplete.insert(artifact_id.to_owned()); + } + if kind == "unknownProfile" { + observed_unknown_profiles.insert(artifact_id.to_owned()); + } + if kind == "invalidOffset" { + observed_invalid_offsets.insert(artifact_id.to_owned()); + } + if kind == "malformedRecord" { + observed_malformed.insert(artifact_id.to_owned()); + } + } + match kind { + "coverageGap" + if artifact_ids.iter().any(|artifact_id| { + artifacts_by_id + .get(artifact_id.as_str()) + .is_some_and(|artifact| { + matches!( + effective_state(artifact), + Ok("captured" | "malformed" | "unsupported") + ) + }) + }) => + { + return Err(format!( + "{observation_id} coverage gap cites complete evidence" + )); + } + "rotationSplit" => { + let rotations = artifact_ids + .iter() + .map(|artifact_id| { + let artifact = artifacts_by_id + .get(artifact_id.as_str()) + .expect("observation artifacts validated"); + ( + required_string(&artifact["rotation"], "kind", artifact_id), + artifact["rotation"]["fragmentComplete"] == false, + artifact["logicalArtifactId"].as_str(), + ) + }) + .collect::>(); + if rotations.len() < 2 + || rotations + .iter() + .any(|(rotation, incomplete, _)| rotation.is_err() || !incomplete) + || !rotations.iter().any(|(rotation, _, _)| { + rotation + .as_ref() + .is_ok_and(|rotation| *rotation == "current") + }) + || !rotations.iter().any(|(rotation, _, _)| { + rotation.as_ref().is_ok_and(|rotation| *rotation == "lo") + }) + || rotations + .iter() + .filter_map(|(_, _, logical)| *logical) + .collect::>() + .len() + != 1 + { + return Err(format!( + "{observation_id} is not a physical incomplete rotation split" + )); + } + } + "unsupportedCandidate" + if artifact_ids.iter().any(|artifact_id| { + let artifact = artifacts_by_id + .get(artifact_id.as_str()) + .expect("observation artifacts validated"); + artifact["catalogState"] != "candidateUnsupported" + || artifact["parserEligible"] != false + || effective_state(artifact) != Ok("unsupported") + }) => + { + return Err(format!( + "{observation_id} promotes a parser-eligible or admitted source" + )); + } + "malformedRecord" + if artifact_ids.iter().any(|artifact_id| { + artifacts_by_id + .get(artifact_id.as_str()) + .is_some_and(|artifact| effective_state(artifact) != Ok("malformed")) + }) => + { + return Err(format!( + "{observation_id} malformed claim lacks malformed coverage" + )); + } + "physicalCollision" => { + let collision_artifacts = artifact_ids + .iter() + .map(|artifact_id| { + artifacts_by_id + .get(artifact_id.as_str()) + .expect("observation artifacts validated") + }) + .collect::>(); + let source_names = collision_artifacts + .iter() + .filter_map(|artifact| artifact["sourceName"].as_str()) + .collect::>(); + let collision_paths = collision_artifacts + .iter() + .filter_map(|artifact| artifact["relativePath"].as_str()) + .collect::>(); + let fingerprints = collision_artifacts + .iter() + .filter_map(|artifact| artifact["pathFingerprint"].as_str()) + .collect::>(); + if collision_artifacts.len() < 2 + || source_names.len() != 1 + || collision_paths.len() != collision_artifacts.len() + || fingerprints.len() != collision_artifacts.len() + { + return Err(format!( + "{observation_id} does not preserve a real cross-root collision" + )); + } + } + "unkeyedRecord" => { + for artifact_id in &artifact_ids { + let artifact = artifacts_by_id + .get(artifact_id.as_str()) + .expect("observation artifacts validated"); + for record in all_artifact_records(scenario_root, artifact)? { + let has_script_key = ["ScriptId", "ExecutionId", "ResourceHandle"] + .iter() + .all(|field| record.fields.contains_key(*field)); + let has_notification_key = + ["NotificationId", "ChannelId", "ResourceHandle"] + .iter() + .all(|field| record.fields.contains_key(*field)); + if has_script_key || has_notification_key { + return Err(format!( + "{observation_id} labels an exact-key record unkeyed" + )); + } + } + } + } + _ => {} + } + } + let mut sorted_observation_order = observation_order.clone(); + sorted_observation_order.sort(); + if observation_order != sorted_observation_order { + return Err("source-local observations are not deterministically sorted".to_owned()); + } + let noncomplete = expected_coverage + .iter() + .filter(|(_, (_, state))| state != "captured") + .map(|(artifact_id, _)| artifact_id.to_owned()) + .collect::>(); + if observed_noncomplete != noncomplete { + return Err(format!( + "noncomplete coverage is not surfaced exactly: {observed_noncomplete:?} != {noncomplete:?}" + )); + } + let malformed = expected_coverage + .iter() + .filter(|(_, (_, state))| state == "malformed") + .map(|(artifact_id, _)| artifact_id.to_owned()) + .collect::>(); + if observed_malformed != malformed { + return Err(format!( + "malformed coverage is not surfaced exactly: {observed_malformed:?} != {malformed:?}" + )); + } + if observed_unknown_profiles != unknown_version_artifacts { + return Err(format!( + "unknown profile observations differ: {observed_unknown_profiles:?} != {unknown_version_artifacts:?}" + )); + } + if observed_invalid_offsets != invalid_offset_artifacts { + return Err(format!( + "invalid offset observations differ: {observed_invalid_offsets:?} != {invalid_offset_artifacts:?}" + )); + } + + Ok(()) +} + +fn mutation_was_accepted( + scenario: &str, + scenario_root: &Path, + manifest: &Value, + expected: &Value, +) -> bool { + validate_contract(scenario, scenario_root, manifest, expected).is_ok() +} + +fn replace_in_file(path: &Path, from: &str, to: &str) { + let original = std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", path.display())); + let mutated = original.replace(from, to); + assert_ne!( + original, + mutated, + "{} contains the mutation target {from:?}", + path.display() + ); + std::fs::write(path, mutated) + .unwrap_or_else(|error| panic!("{} is writable: {error}", path.display())); +} + +#[test] +fn management_fixture_matrix_is_exact_and_workflow_scoped() { + assert_eq!( + scenario_names(), + SCENARIOS.map(str::to_owned), + "fixture directories are an explicit issue #326 matrix" + ); + let workflow_counts = SCENARIOS + .iter() + .map(|scenario| { + let (_, manifest, _) = load_contract(scenario); + manifest["workflowFamily"] + .as_str() + .expect("workflowFamily is a string") + .to_owned() + }) + .fold(BTreeMap::new(), |mut counts, workflow| { + *counts.entry(workflow).or_insert(0usize) += 1; + counts + }); + assert_eq!( + workflow_counts, + BTreeMap::from([ + ("coManagement".to_owned(), 4), + ("mixed".to_owned(), 1), + ("notification".to_owned(), 3), + ("scripts".to_owned(), 4), + ("softwareCenter".to_owned(), 2), + ]) + ); +} + +#[test] +fn management_corpus_inventory_and_digest_are_pinned() { + let inventory = corpus_inventory(); + assert_eq!(inventory.scenarios, 14); + assert_eq!(inventory.artifacts, 30); + assert_eq!(inventory.evidence_files, 25); + assert_eq!(inventory.evidence_bytes, 8_648); + assert_eq!( + inventory.capture_states, + BTreeMap::from([ + ("absent".to_owned(), 3), + ("accessDenied".to_owned(), 1), + ("capped".to_owned(), 1), + ("captured".to_owned(), 23), + ("parseFailed".to_owned(), 1), + ("unsupported".to_owned(), 1), + ]) + ); + assert_eq!( + inventory.digest, DOCUMENTED_CORPUS_DIGEST, + "path/artifact-qualified evidence digest changed" + ); +} + +#[test] +fn parse_failed_capture_maps_to_malformed_effective_coverage() { + let (_, manifest, expected) = load_contract("software-center-insufficient"); + let artifact = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .find(|artifact| artifact["artifactId"] == "software-center-insufficient-malformed") + .expect("malformed candidate artifact is present"); + assert_eq!(artifact["captureState"], "parseFailed"); + assert_eq!(effective_state(artifact), Ok("malformed")); + + let coverage = expected["coverage"] + .as_array() + .expect("coverage is an array") + .iter() + .find(|row| row["artifactId"] == "software-center-insufficient-malformed") + .expect("malformed candidate has explicit coverage"); + assert_eq!(coverage["state"], "malformed"); +} + +#[test] +fn every_management_scenario_satisfies_the_preparation_contract() { + for scenario in SCENARIOS { + let (scenario_root, manifest, expected) = load_contract(scenario); + validate_contract(scenario, &scenario_root, &manifest, &expected) + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + } +} + +#[test] +fn ownership_and_operational_outcomes_remain_conservative() { + let (_, intune_manifest, intune) = load_contract("co-management-intune-owned"); + assert_eq!(intune["ownership"]["classification"], "IntuneOwned"); + assert_eq!(intune["ownership"]["terminalHandoff"], true); + assert_eq!(intune["transactions"], Value::Array(Vec::new())); + assert_eq!(intune["findings"], Value::Array(Vec::new())); + assert_eq!( + intune_manifest["artifacts"][0]["logicalArtifactId"], + "client-co-management" + ); + + let (_, transitioning_manifest, transitioning) = load_contract("co-management-transitioning"); + assert_eq!( + transitioning["ownership"]["classification"], + "SharedOrTransitioning" + ); + assert_eq!(transitioning["ownership"]["confidence"], "medium"); + assert_eq!(transitioning["transactions"], Value::Array(Vec::new())); + assert_eq!( + transitioning_manifest["artifacts"][0]["captureState"], + "captured" + ); + + let (_, _, deferred) = load_contract("notification-deferred"); + assert_eq!( + deferred["transactions"][0]["classification"], + "blockedOrDeferred" + ); + assert_eq!(deferred["transactions"][0]["state"], "deferred"); + assert_eq!(deferred["transactions"][0]["confidence"], "medium"); + + let (_, _, software_center) = load_contract("software-center-observed"); + assert_eq!( + software_center["extractionProfile"]["selectionState"], + "unsupportedCandidate" + ); + assert_eq!(software_center["transactions"], Value::Array(Vec::new())); + assert_eq!(software_center["findings"], Value::Array(Vec::new())); +} + +#[test] +fn incomplete_rotation_collision_and_same_time_inputs_stay_unlinked() { + let (_, _, incomplete) = load_contract("script-incomplete"); + assert_eq!(incomplete["transactions"], Value::Array(Vec::new())); + assert_eq!(incomplete["coverage"][0]["state"], "capped"); + assert_eq!(incomplete["coverage"][1]["state"], "partial"); + + let (_, mixed_manifest, mixed) = load_contract("mixed-unrelated"); + assert_eq!(mixed["ownership"]["classification"], "UnknownOwnership"); + assert_eq!(mixed["transactions"], Value::Array(Vec::new())); + assert_eq!(mixed["findings"], Value::Array(Vec::new())); + assert_ne!( + mixed_manifest["artifacts"][3]["pathFingerprint"], + mixed_manifest["artifacts"][4]["pathFingerprint"], + "same-basename roots retain distinct physical provenance" + ); +} + +#[test] +fn fixture_bytes_are_synthetic_sanitized_and_context_safe() { + for scenario in SCENARIOS { + let scenario_root = management_root().join(scenario); + let manifest = load_json(&scenario_root.join("manifest.json")); + for artifact in manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + { + if let Some(path) = artifact["sanitizedSourcePath"].as_str() { + assert!( + path.starts_with("SYNTHETIC://"), + "{scenario} contains an unsanitized source path" + ); + } + let Some(relative_path) = artifact["relativePath"].as_str() else { + continue; + }; + let text = std::fs::read_to_string(scenario_root.join(relative_path)) + .expect("synthetic evidence is readable"); + assert!(text.contains("SYNTHETIC FIXTURE")); + let lower = text.to_ascii_lowercase(); + for forbidden in [ + "c:\\users\\", + "/users/", + "s-1-5-", + "password=", + "token=", + "contoso", + "customer", + "example.com", + ] { + assert!( + !lower.contains(forbidden), + "{scenario}/{relative_path} contains forbidden context {forbidden}" + ); + } + } + } +} + +#[test] +fn adversarial_role_source_path_and_collision_mutations_fail_closed() { + let mut accepted = Vec::new(); + + let (scenario_root, manifest, expected) = load_contract("script-success"); + let mut role_alias = manifest.clone(); + role_alias["artifacts"][0]["role"] = Value::String("server".to_owned()); + if mutation_was_accepted("script-success", &scenario_root, &role_alias, &expected) { + accepted.push("artifact role changed to server"); + } + + let mut source_alias = manifest.clone(); + source_alias["artifacts"][0]["sourceName"] = Value::String("scripts.LOG".to_owned()); + if mutation_was_accepted("script-success", &scenario_root, &source_alias, &expected) { + accepted.push("case-folded source alias"); + } + + let mut unsafe_source_path = manifest.clone(); + unsafe_source_path["artifacts"][0]["sanitizedSourcePath"] = + Value::String("C:\\Users\\SYNTHETIC\\Scripts.log".to_owned()); + if mutation_was_accepted( + "script-success", + &scenario_root, + &unsafe_source_path, + &expected, + ) { + accepted.push("raw Windows source path"); + } + + let mut logical_alias = manifest.clone(); + logical_alias["artifacts"][0]["logicalArtifactId"] = Value::String("client-script".to_owned()); + if mutation_was_accepted("script-success", &scenario_root, &logical_alias, &expected) { + accepted.push("logical source alias"); + } + + let (mixed_root, mixed_manifest, mixed_expected) = load_contract("mixed-unrelated"); + let mut fingerprint_collision = mixed_manifest.clone(); + fingerprint_collision["artifacts"][4]["pathFingerprint"] = + fingerprint_collision["artifacts"][3]["pathFingerprint"].clone(); + if mutation_was_accepted( + "mixed-unrelated", + &mixed_root, + &fingerprint_collision, + &mixed_expected, + ) { + accepted.push("cross-root path fingerprint collision"); + } + + assert!( + accepted.is_empty(), + "adversarial manifest mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn adversarial_key_profile_coverage_and_invalid_offset_mutations_fail_closed() { + let mut accepted = Vec::new(); + let (scenario_root, manifest, expected) = load_contract("script-success"); + + let mut key_alias = expected.clone(); + key_alias["transactions"][0]["key"]["ExecutionId"] = + Value::String("EXEC-326-BORROWED".to_owned()); + if mutation_was_accepted("script-success", &scenario_root, &manifest, &key_alias) { + accepted.push("borrowed exact key"); + } + + let mut profile_alias = expected.clone(); + profile_alias["extractionProfile"]["id"] = + Value::String("sccm-client-scripts-latest".to_owned()); + if mutation_was_accepted("script-success", &scenario_root, &manifest, &profile_alias) { + accepted.push("unversioned profile alias"); + } + + let (incomplete_root, incomplete_manifest, incomplete_expected) = + load_contract("script-incomplete"); + let mut coverage_alias = incomplete_expected.clone(); + coverage_alias["coverage"][0]["state"] = Value::String("captured".to_owned()); + if mutation_was_accepted( + "script-incomplete", + &incomplete_root, + &incomplete_manifest, + &coverage_alias, + ) { + accepted.push("capped coverage promoted to captured"); + } + + let mut unknown_partial_manifest = incomplete_manifest.clone(); + unknown_partial_manifest["artifacts"][1]["sourceVersion"] = + Value::String("5.99.UNKNOWN.3260".to_owned()); + let mut unknown_partial_expected = incomplete_expected.clone(); + unknown_partial_expected["sourceLocalObservations"] + .as_array_mut() + .expect("observations are an array") + .push(serde_json::json!({ + "observationId": "script-incomplete-unknown-profile", + "kind": "unknownProfile", + "claim": "The partial source has no validated extraction profile.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": ["script-incomplete-lo"] + })); + unknown_partial_expected["sourceLocalObservations"] + .as_array_mut() + .expect("observations are an array") + .sort_by(|left, right| { + left["observationId"] + .as_str() + .cmp(&right["observationId"].as_str()) + }); + if mutation_was_accepted( + "script-incomplete", + &incomplete_root, + &unknown_partial_manifest, + &unknown_partial_expected, + ) { + accepted.push("unknown source retained a selected extraction profile"); + } + + let temporary = copy_scenario_to_temporary_root("script-success", "invalid-offset"); + let evidence_path = temporary + .root + .join("evidence/client-scripts/current/Scripts.log"); + let original = std::fs::read_to_string(&evidence_path).expect("temporary evidence is readable"); + std::fs::write(&evidence_path, original.replace("+000", "+2500")) + .expect("temporary evidence offset is mutated"); + let temporary_manifest = load_json(&temporary.root.join("manifest.json")); + let temporary_expected = load_json(&temporary.root.join("expected.json")); + if mutation_was_accepted( + "script-success", + &temporary.root, + &temporary_manifest, + &temporary_expected, + ) { + accepted.push("invalid timestamp offset retained high confidence"); + } + + assert!( + accepted.is_empty(), + "identity/profile/coverage mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn adversarial_reversed_phase_late_ownership_and_equal_time_fail_closed() { + let mut accepted = Vec::new(); + + let reversed_phase = + copy_scenario_to_temporary_root("script-success", "reversed-terminal-phase"); + let reversed_phase_path = reversed_phase + .root + .join("evidence/client-scripts/current/Scripts.log"); + let original = + std::fs::read_to_string(&reversed_phase_path).expect("temporary evidence is readable"); + std::fs::write( + &reversed_phase_path, + original.replace("11:00:03.000+000", "10:59:59.000+000"), + ) + .expect("terminal phase timestamp is moved before receive"); + let manifest = load_json(&reversed_phase.root.join("manifest.json")); + let expected = load_json(&reversed_phase.root.join("expected.json")); + if mutation_was_accepted("script-success", &reversed_phase.root, &manifest, &expected) { + accepted.push("terminal script phase predates receive"); + } + + let late_ownership = copy_scenario_to_temporary_root("notification-received", "late-ownership"); + let late_ownership_path = late_ownership + .root + .join("evidence/client-co-management/current/CoManagementHandler.log"); + let original = + std::fs::read_to_string(&late_ownership_path).expect("temporary evidence is readable"); + std::fs::write( + &late_ownership_path, + original.replace("12:00:00.000+000", "12:00:03.000+000"), + ) + .expect("ownership timestamp is moved after operational evidence"); + let manifest = load_json(&late_ownership.root.join("manifest.json")); + let expected = load_json(&late_ownership.root.join("expected.json")); + if mutation_was_accepted( + "notification-received", + &late_ownership.root, + &manifest, + &expected, + ) { + accepted.push("ownership evidence postdates the transaction"); + } + + let equal_time = copy_scenario_to_temporary_root("notification-deferred", "equal-phase-time"); + let equal_time_path = equal_time + .root + .join("evidence/client-notification/current/CcmNotificationAgent.log"); + let original = + std::fs::read_to_string(&equal_time_path).expect("temporary evidence is readable"); + std::fs::write( + &equal_time_path, + original.replace("12:01:02.000+000", "12:01:01.000+000"), + ) + .expect("distinct phases are assigned the same timestamp"); + let manifest = load_json(&equal_time.root.join("manifest.json")); + let expected = load_json(&equal_time.root.join("expected.json")); + if mutation_was_accepted( + "notification-deferred", + &equal_time.root, + &manifest, + &expected, + ) { + accepted.push("distinct notification phases share an ambiguous timestamp"); + } + + assert!( + accepted.is_empty(), + "temporal provenance mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn unsupported_candidate_and_causal_claim_mutations_fail_closed() { + let mut accepted = Vec::new(); + let (scenario_root, manifest, expected) = load_contract("software-center-observed"); + + let mut promoted_manifest = manifest.clone(); + promoted_manifest["artifacts"][0]["catalogState"] = Value::String("admitted".to_owned()); + promoted_manifest["artifacts"][0]["parserEligible"] = Value::Bool(true); + let mut promoted_expected = expected.clone(); + promoted_expected["coverage"][0]["state"] = Value::String("captured".to_owned()); + if mutation_was_accepted( + "software-center-observed", + &scenario_root, + &promoted_manifest, + &promoted_expected, + ) { + accepted.push("unsupported Software Center candidate promoted coherently"); + } + + let mut causal_claim = expected.clone(); + causal_claim["sourceLocalObservations"][0]["claim"] = + Value::String("The server caused the Software Center failure.".to_owned()); + if mutation_was_accepted( + "software-center-observed", + &scenario_root, + &manifest, + &causal_claim, + ) { + accepted.push("unsupported server causal claim"); + } + + let (intune_root, intune_manifest, mut intune_expected) = + load_contract("script-intune-handoff"); + let (_, _, failure_expected) = load_contract("script-failure"); + intune_expected["transactions"] = failure_expected["transactions"].clone(); + if mutation_was_accepted( + "script-intune-handoff", + &intune_root, + &intune_manifest, + &intune_expected, + ) { + accepted.push("Intune handoff promoted to SCCM transaction causality"); + } + + assert!( + accepted.is_empty(), + "unsupported capability/causal mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn exact_record_field_and_envelope_mutations_fail_closed() { + let mut accepted = Vec::new(); + + let key_lookalike = copy_scenario_to_temporary_root("script-success", "other-script-id"); + let key_path = key_lookalike + .root + .join("evidence/client-scripts/current/Scripts.log"); + replace_in_file(&key_path, "ScriptId=", "OtherScriptId="); + let manifest = load_json(&key_lookalike.root.join("manifest.json")); + let expected = load_json(&key_lookalike.root.join("expected.json")); + if mutation_was_accepted("script-success", &key_lookalike.root, &manifest, &expected) { + accepted.push("OtherScriptId satisfied ScriptId"); + } + + let terminal_lookalike = + copy_scenario_to_temporary_root("script-success", "other-terminal-fields"); + let terminal_path = terminal_lookalike + .root + .join("evidence/client-scripts/current/Scripts.log"); + replace_in_file(&terminal_path, "Disposition=", "OtherDisposition="); + replace_in_file(&terminal_path, "Terminal=", "OtherTerminal="); + let manifest = load_json(&terminal_lookalike.root.join("manifest.json")); + let expected = load_json(&terminal_lookalike.root.join("expected.json")); + if mutation_was_accepted( + "script-success", + &terminal_lookalike.root, + &manifest, + &expected, + ) { + accepted.push("lookalike disposition and terminal fields"); + } + + let duplicate_key = copy_scenario_to_temporary_root("script-success", "conflicting-script-id"); + let duplicate_key_path = duplicate_key + .root + .join("evidence/client-scripts/current/Scripts.log"); + replace_in_file( + &duplicate_key_path, + "ScriptId=SCRIPT-326-SUCCESS", + "ScriptId=SCRIPT-326-SUCCESS ScriptId=SCRIPT-326-SHADOW", + ); + let manifest = load_json(&duplicate_key.root.join("manifest.json")); + let expected = load_json(&duplicate_key.root.join("expected.json")); + if mutation_was_accepted("script-success", &duplicate_key.root, &manifest, &expected) { + accepted.push("conflicting duplicate ScriptId"); + } + + let nested_ccm = copy_scenario_to_temporary_root("script-success", "nested-ccm-envelope"); + let nested_ccm_path = nested_ccm + .root + .join("evidence/client-scripts/current/Scripts.log"); + replace_in_file( + &nested_ccm_path, + "]LOG]!>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureArtifact { + artifact_id: String, + role: String, + capture_state: String, + encoding: Option, + original_basename: String, + path_fingerprint: Option, + rotation: FixtureRotation, + source_version: Option, + captured_utc: Option, + relative_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FixtureRotation { + kind: String, + value: Option, + lineage_id: Option, + fragment_complete: Option, +} + +fn fixture_directory(scenario: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join(FIXTURE_ROOT) + .join(scenario) +} + +fn fixture_input(scenario: &str) -> (SccmClientIntakeBundle, Vec) { + let root = fixture_directory(scenario); + let manifest: FixtureManifest = serde_json::from_str( + &fs::read_to_string(root.join("manifest.json")).expect("manifest is readable"), + ) + .expect("manifest is valid"); + let mut payloads = Vec::new(); + let artifacts = manifest + .artifacts + .into_iter() + .map(|fixture| { + assert_eq!(fixture.role, "client"); + let artifact_id = format!("fixture-{}", fixture.artifact_id); + let coverage = fixture_coverage(&fixture.capture_state); + let rotation_lineage = fixture + .rotation + .lineage_id + .as_ref() + .map(|_| "synthetic:policy-rotation-boundary".to_owned()); + let complete_capture = coverage == SccmCoverageState::Captured + && fixture.rotation.fragment_complete == Some(true); + let bytes = complete_capture.then(|| { + fs::read( + root.join( + fixture + .relative_path + .as_deref() + .expect("complete capture has a relative path"), + ), + ) + .expect("payload is readable") + }); + let declared_byte_length = bytes + .as_ref() + .map(|bytes| u64::try_from(bytes.len()).expect("fixture length fits u64")); + let content_sha256 = bytes.as_ref().map(|bytes| hex_sha256(bytes)); + if let Some(bytes) = bytes { + payloads.push( + SccmClientCapturedPayload::new(artifact_id.clone(), bytes) + .expect("fixture payload is bounded"), + ); + } + SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id, + display_name: fixture.original_basename, + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: fixture.source_version, + collected_at_utc: fixture.captured_utc, + rotation: fixture_rotation(&fixture.rotation), + coverage, + encoding: fixture.encoding, + }, + path_fingerprint: rotation_lineage + .as_ref() + .map(|_| "synthetic:policy-rotation-boundary".to_owned()) + .or(fixture.path_fingerprint), + rotation_lineage, + relative_path: fixture.relative_path, + fragment_complete: fixture.rotation.fragment_complete, + declared_byte_length, + content_sha256, + } + }) + .collect(); + ( + SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }, + payloads, + ) +} + +fn analyze_fixture(scenario: &str) -> cmtraceopen_parser::sccm::SccmPolicyAnalysis { + let (bundle, payloads) = fixture_input(scenario); + let assessment = assess_client_intake(&bundle).expect("fixture intake is canonical"); + analyze_client_policy(&bundle, &assessment, &payloads) + .unwrap_or_else(|error| panic!("{scenario} policy analysis succeeds: {error:?}")) +} + +fn fixture_rotation(rotation: &FixtureRotation) -> SccmRotation { + match rotation.kind.as_str() { + "current" => SccmRotation::Current, + "lo" => SccmRotation::LoUnderscore, + "numbered" => SccmRotation::Numbered(rotation.value.expect("numbered value")), + other => panic!("unsupported rotation {other}"), + } +} + +fn fixture_coverage(value: &str) -> SccmCoverageState { + match value { + "captured" => SccmCoverageState::Captured, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "capped" => SccmCoverageState::Capped, + "parseFailed" => SccmCoverageState::ParseFailed, + other => panic!("unsupported fixture coverage {other}"), + } +} + +fn hex_sha256(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn single_policy_input( + artifact_id: &str, + basename: &str, + records: &[&str], +) -> (SccmClientIntakeBundle, Vec) { + let bytes = records.join("\n").into_bytes(); + let canonical_id = format!("fixture-{artifact_id}"); + let bundle = SccmClientIntakeBundle { + artifacts: vec![SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: canonical_id.clone(), + 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: Some("2026-08-04T12:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some(format!("synthetic:{artifact_id}")), + rotation_lineage: None, + relative_path: Some(format!("evidence/client-policy-agent/current/{basename}")), + fragment_complete: Some(true), + declared_byte_length: Some(u64::try_from(bytes.len()).expect("length")), + content_sha256: Some(hex_sha256(&bytes)), + }], + capture_gaps: Vec::new(), + }; + let payloads = + vec![SccmClientCapturedPayload::new(canonical_id, bytes).expect("bounded payload")]; + (bundle, payloads) +} + +#[test] +fn every_policy_fixture_runs_through_production_with_exact_oracles() { + let oracle_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join(FIXTURE_ROOT) + .join("production-oracles.json"); + let oracles: Value = serde_json::from_str( + &fs::read_to_string(&oracle_path).expect("production oracle is committed"), + ) + .expect("production oracle is valid JSON"); + + for scenario in SCENARIOS { + let analysis = analyze_fixture(scenario); + assert_eq!( + serde_json::to_value(&analysis).expect("analysis serializes"), + oracles[*scenario], + "{scenario}" + ); + assert!(analysis.findings.iter().all(|finding| { + finding.validate().is_ok() + && !finding.evidence.is_empty() + && finding + .evidence + .iter() + .all(|reference| reference.line_start.is_some() && reference.line_end.is_some()) + })); + assert!(!analysis.time_only_causality_allowed); + + let (mut reversed_bundle, mut reversed_payloads) = fixture_input(scenario); + reversed_bundle.artifacts.reverse(); + reversed_payloads.reverse(); + let reversed_assessment = + assess_client_intake(&reversed_bundle).expect("reordered intake is canonical"); + let reversed = + analyze_client_policy(&reversed_bundle, &reversed_assessment, &reversed_payloads) + .expect("reordered analysis succeeds"); + assert_eq!( + serde_json::to_value(&analysis).expect("analysis serializes"), + serde_json::to_value(reversed).expect("reordered analysis serializes"), + "input order changed output for {scenario}" + ); + } + assert_eq!( + oracles.as_object().expect("oracle object").len(), + SCENARIOS.len() + ); + assert!(analyze_fixture("complete").cross_source_correlation_performed); + assert!(!analyze_fixture("download-failure").cross_source_correlation_performed); +} + +#[test] +fn policy_acceptance_states_are_distinct_and_conservative() { + assert!( + analyze_fixture("complete") + .extraction_profile + .synthetic_fixture_only + ); + assert_eq!( + analyze_fixture("complete").transactions[0].state, + SccmPolicyState::Succeeded + ); + assert_eq!( + analyze_fixture("request-auth-failure").transactions[0].condition, + Some(SccmPolicyCondition::TransferAuthenticationFailure) + ); + assert_eq!( + analyze_fixture("scheduler-deferred").transactions[0].state, + SccmPolicyState::Deferred + ); + assert_eq!( + analyze_fixture("gate-c-contradictory").transactions[0].state, + SccmPolicyState::Contradictory + ); + assert_eq!( + analyze_fixture("incomplete").transactions[0].state, + SccmPolicyState::Incomplete + ); + assert_eq!( + analyze_fixture("malformed") + .extraction_profile + .selection_state, + SccmPolicyProfileSelectionState::UnvalidatedVersion + ); + assert!(analyze_fixture("rotation-split").transactions.is_empty()); +} + +#[test] +fn authority_mutations_fail_closed_without_caller_profiles_or_evidence() { + let (bundle, payloads) = fixture_input("complete"); + let mut assessment = assess_client_intake(&bundle).expect("canonical"); + assessment.groups[0].logical_artifact_id = "forged-policy-group".to_owned(); + assert!(analyze_client_policy(&bundle, &assessment, &payloads).is_err()); + + let (bundle, mut payloads) = fixture_input("complete"); + payloads[0] = SccmClientCapturedPayload::new( + bundle.artifacts[0].artifact.artifact_id.clone(), + b"forged payload".to_vec(), + ) + .expect("bounded forged payload"); + let assessment = assess_client_intake(&bundle).expect("canonical"); + assert!(analyze_client_policy(&bundle, &assessment, &payloads).is_err()); +} + +#[test] +fn observation_identity_and_public_privacy_are_collision_safe() { + for scenario in SCENARIOS { + let analysis = analyze_fixture(scenario); + let mut ids = BTreeSet::new(); + for observation in analysis + .transactions + .iter() + .flat_map(|transaction| transaction.observations.iter()) + { + assert!(ids.insert(observation.observation_id.clone()), "{scenario}"); + assert!(observation + .observation_id + .contains(&observation.evidence.artifact_id)); + assert!(observation.observation_id.contains( + &observation + .evidence + .line_start + .expect("admitted line") + .to_string() + )); + } + let public = serde_json::to_string(&analysis).expect("analysis serializes"); + assert!(!public.contains("SYNTHETIC://"), "{scenario}"); + assert!(!public.contains("safe:client:"), "{scenario}"); + assert!(!public.contains("safe:mp:"), "{scenario}"); + assert!(!public.contains("LAB-CLIENT"), "{scenario}"); + } +} + +#[test] +fn exact_keys_not_time_join_policy_transactions() { + let analysis = analyze_fixture("gate-c-contradictory"); + assert_eq!(analysis.transactions.len(), 2); + assert_ne!( + analysis.transactions[0].key.assignment_id, + analysis.transactions[1].key.assignment_id + ); + assert!(analysis.transactions.iter().all(|transaction| { + transaction.confidence == SccmConfidence::High + || transaction.state == SccmPolicyState::Contradictory + })); + assert!(analysis + .transactions + .iter() + .flat_map(|transaction| &transaction.correlation_keys) + .all(|key| { + key.confidence == SccmKeyConfidence::Exact + && key.extraction_profile_id.as_deref() == Some(SCCM_POLICY_KEY_PROFILE_ID) + })); +} + +#[test] +fn conflicting_and_unkeyed_same_time_records_never_merge() { + let first = ""; + let conflicting = ""; + let (bundle, payloads) = single_policy_input( + "policy-contradictory", + "PolicyAgent.log", + &[first, conflicting], + ); + let assessment = assess_client_intake(&bundle).expect("canonical"); + let analysis = analyze_client_policy(&bundle, &assessment, &payloads).expect("analyzed"); + assert!(analysis.transactions.is_empty()); + assert_eq!(analysis.source_local_observations.len(), 2); + assert!(analysis + .source_local_observations + .iter() + .all(|observation| { + observation.condition == SccmPolicyCondition::ConflictingEvidence + && !observation.correlation_eligible + })); + + let unkeyed = ""; + let (bundle, payloads) = + single_policy_input("policy-gate", "PolicyAgent.log", &[first, unkeyed]); + let assessment = assess_client_intake(&bundle).expect("canonical"); + let analysis = analyze_client_policy(&bundle, &assessment, &payloads).expect("analyzed"); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!(analysis.transactions[0].observations.len(), 1); + assert_eq!(analysis.source_local_observations.len(), 1); + assert!(!analysis.source_local_observations[0].correlation_eligible); +} + +#[test] +fn no_assignment_stale_assignment_and_corrupt_processing_remain_distinct() { + let no_assignment = ""; + let (bundle, payloads) = single_policy_input("policy-no", "PolicyAgent.log", &[no_assignment]); + let assessment = assess_client_intake(&bundle).expect("canonical"); + let analysis = analyze_client_policy(&bundle, &assessment, &payloads).expect("analyzed"); + assert!(analysis.transactions.is_empty()); + assert_eq!( + analysis.source_local_observations[0].condition, + SccmPolicyCondition::NoAssignment + ); + + let stale = ""; + let (bundle, payloads) = single_policy_input("policy-deferred", "Scheduler.log", &[stale]); + let assessment = assess_client_intake(&bundle).expect("canonical"); + let analysis = analyze_client_policy(&bundle, &assessment, &payloads).expect("analyzed"); + assert_eq!(analysis.transactions[0].state, SccmPolicyState::Deferred); + assert_eq!( + analysis.transactions[0].condition, + Some(SccmPolicyCondition::StaleAssignment) + ); + + let corrupt = ""; + let (bundle, payloads) = + single_policy_input("policy-persist-failure", "PolicyAgent.log", &[corrupt]); + let assessment = assess_client_intake(&bundle).expect("canonical"); + let analysis = analyze_client_policy(&bundle, &assessment, &payloads).expect("analyzed"); + assert_eq!(analysis.transactions[0].state, SccmPolicyState::Failed); + assert_eq!( + analysis.transactions[0].condition, + Some(SccmPolicyCondition::ProcessingFailure) + ); + assert_eq!(analysis.findings[0].terminal_evidence.len(), 1); +} + +#[test] +fn nonterminal_failure_is_only_a_low_confidence_symptom() { + let record = ""; + let (bundle, payloads) = single_policy_input("policy-failure", "PolicyAgent.log", &[record]); + let assessment = assess_client_intake(&bundle).expect("canonical"); + let analysis = analyze_client_policy(&bundle, &assessment, &payloads).expect("analyzed"); + + assert_eq!(analysis.transactions[0].state, SccmPolicyState::Observed); + assert_eq!( + analysis.transactions[0].classification, + SccmPolicyClassification::LowConfidenceSymptom + ); + assert_eq!(analysis.transactions[0].confidence, SccmConfidence::Low); + assert!(analysis.findings[0].terminal_evidence.is_empty()); +} + +#[test] +fn capped_policy_source_is_an_explicit_transaction_gap() { + let record = ""; + let (mut bundle, payloads) = single_policy_input("policy-capped", "PolicyAgent.log", &[record]); + bundle.capture_gaps.push(SccmClientIntakeCaptureGap { + artifact_id: "fixture-policy-capped-rotation".to_owned(), + basename: "PolicyAgent.log.1".to_owned(), + rotation: SccmRotation::Numbered(1), + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic-policy-capped-rotation".to_owned(), + rotation_lineage: "synthetic:policy-capped-rotation".to_owned(), + }); + let assessment = assess_client_intake(&bundle).expect("canonical capped declaration"); + let analysis = analyze_client_policy(&bundle, &assessment, &payloads).expect("analyzed"); + + assert_eq!(analysis.transactions[0].state, SccmPolicyState::Incomplete); + assert!(analysis.transactions[0].coverage_gaps.iter().any(|gap| { + gap.artifact_id == "client-policy-agent" && gap.coverage == SccmCoverageState::Capped + })); + assert!(analysis.transactions[0] + .next_artifacts + .iter() + .any(|request| request.logical_id == "policyAgent")); +} + +fn policy_record(message: &str, time: &str, offset: &str, component: &str) -> String { + format!( + "" + ) +} + +fn analyze_policy_records( + artifact_id: &str, + records: &[String], +) -> cmtraceopen_parser::sccm::SccmPolicyAnalysis { + let borrowed = records.iter().map(String::as_str).collect::>(); + let (bundle, payloads) = single_policy_input(artifact_id, "PolicyAgent.log", &borrowed); + let assessment = assess_client_intake(&bundle).expect("canonical chronology input"); + analyze_client_policy(&bundle, &assessment, &payloads).expect("chronology analysis") +} + +#[test] +fn cross_phase_chronology_precedes_failure_deferred_and_success_decisions() { + let cases = [ + ( + "policy-failure", + vec![ + policy_record("Request succeeded", "12:10:00.000", "+000", "PolicyAgent"), + policy_record( + "Download failed terminal", + "12:05:00.000", + "+000", + "PolicyAgent", + ), + ], + ), + ( + "policy-deferred", + vec![ + policy_record("Request succeeded", "12:10:00.000", "+000", "PolicyAgent"), + policy_record("Schedule deferred", "12:05:00.000", "+000", "Scheduler"), + ], + ), + ( + "policy-success", + vec![ + policy_record("Request succeeded", "12:10:00.000", "+000", "PolicyAgent"), + policy_record("Download succeeded", "12:05:00.000", "+000", "PolicyAgent"), + ], + ), + ]; + + for (artifact_id, records) in cases { + let analysis = analyze_policy_records(artifact_id, &records); + let transaction = &analysis.transactions[0]; + assert_eq!( + transaction.state, + SccmPolicyState::Contradictory, + "{artifact_id}" + ); + assert_eq!(transaction.confidence, SccmConfidence::Low, "{artifact_id}"); + assert_eq!( + transaction.classification, + SccmPolicyClassification::ContradictoryEvidence, + "{artifact_id}" + ); + assert_eq!(analysis.findings[0].class, SccmFindingClass::Symptom); + assert!(analysis.findings[0].terminal_evidence.is_empty()); + } +} + +#[test] +fn chronology_conflict_confirms_only_a_successful_ordered_prefix() { + let cases = [ + ("policy-failure", "Request failed terminal", None), + ("policy-deferred", "Request deferred", None), + ( + "policy-success", + "Request succeeded", + Some(SccmPolicyPhase::Request), + ), + ]; + + for (artifact_id, earlier, expected_last_confirmed) in cases { + let records = vec![ + policy_record(earlier, "12:10:00.000", "+000", "PolicyAgent"), + policy_record("Download succeeded", "12:05:00.000", "+000", "PolicyAgent"), + ]; + let analysis = analyze_policy_records(artifact_id, &records); + let transaction = &analysis.transactions[0]; + assert_eq!( + transaction.phase, + SccmPolicyPhase::Download, + "{artifact_id}" + ); + assert_eq!( + transaction.state, + SccmPolicyState::Contradictory, + "{artifact_id}" + ); + assert_eq!( + transaction.condition, + Some(SccmPolicyCondition::OrderingUnavailable), + "{artifact_id}" + ); + assert_eq!( + transaction.last_confirmed_phase, expected_last_confirmed, + "{artifact_id}" + ); + assert_eq!(analysis.findings[0].class, SccmFindingClass::Symptom); + assert!(analysis.findings[0].terminal_evidence.is_empty()); + } +} + +#[test] +fn equal_and_noncomparable_cross_phase_times_are_ambiguous() { + let cases = [ + ( + "policy-time", + vec![ + policy_record("Request succeeded", "12:10:00.000", "+000", "PolicyAgent"), + policy_record( + "Download failed terminal", + "12:10:00.000", + "+000", + "PolicyAgent", + ), + ], + ), + ( + "policy-offset", + vec![ + policy_record("Request succeeded", "12:10:00.000", "+000", "PolicyAgent"), + policy_record( + "Download failed terminal", + "12:11:00.000", + "+9999", + "PolicyAgent", + ), + ], + ), + ]; + + for (artifact_id, records) in cases { + let analysis = analyze_policy_records(artifact_id, &records); + assert_eq!( + analysis.transactions[0].state, + SccmPolicyState::Contradictory + ); + assert_eq!(analysis.transactions[0].confidence, SccmConfidence::Low); + assert_eq!(analysis.findings[0].class, SccmFindingClass::Symptom); + assert!(analysis.findings[0].terminal_evidence.is_empty()); + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_policy_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_policy_fixture_contract.rs new file mode 100644 index 000000000..9917d9e61 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_policy_fixture_contract.rs @@ -0,0 +1,163 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +const FIXTURE_ROOT: &str = "tests/fixtures/sccm/client/policy"; +const SCENARIOS: &[&str] = &[ + "complete", + "contradictory-offset", + "download-failure", + "evaluation-failure", + "gate-c-contradictory", + "incomplete", + "malformed", + "multiline", + "persist-failure", + "recovery", + "reporting-failure", + "request-auth-failure", + "rotation-split", + "scheduler-deferred", +]; + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_ROOT) +} + +fn read_json(scenario: &str, name: &str) -> Value { + serde_json::from_str( + &fs::read_to_string(root().join(scenario).join(name)).expect("fixture is readable"), + ) + .expect("fixture is valid JSON") +} + +#[test] +fn preparation_corpus_is_closed_and_self_identifying() { + let actual = fs::read_dir(root()) + .expect("fixture root") + .filter_map(Result::ok) + .filter(|entry| entry.path().is_dir()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + actual, + SCENARIOS + .iter() + .map(|scenario| (*scenario).to_owned()) + .collect() + ); + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json"); + let expected = read_json(scenario, "expected.json"); + assert_eq!(manifest["sccmManifestVersion"], 1, "{scenario}"); + assert_eq!(manifest["proposalOnly"], true, "{scenario}"); + assert_eq!(manifest["syntheticFixture"], true, "{scenario}"); + assert_eq!(manifest["bundle"]["role"], "client", "{scenario}"); + assert_eq!(manifest["bundle"]["workflow"], "policy", "{scenario}"); + assert_eq!(expected["scenario"], *scenario, "{scenario}"); + assert_eq!(expected["workflow"], "policy", "{scenario}"); + assert_eq!( + expected["contractState"], "proposedPending318", + "{scenario}" + ); + assert_eq!( + expected["stateChain"], + serde_json::json!(["request", "download", "persist", "schedule", "evaluate", "report"]), + "{scenario}" + ); + } +} + +#[test] +fn preparation_evidence_ranges_resolve_to_declared_physical_artifacts() { + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json"); + let expected = read_json(scenario, "expected.json"); + let artifacts = manifest["artifacts"] + .as_array() + .expect("artifacts") + .iter() + .map(|artifact| { + let artifact_id = artifact["artifactId"].as_str().expect("artifact id"); + let relative_path = artifact["relativePath"].as_str(); + let line_count = relative_path.map_or(0, |relative_path| { + fs::read_to_string(root().join(scenario).join(relative_path)) + .expect("declared payload") + .lines() + .count() + }); + (artifact_id.to_owned(), line_count) + }) + .collect::>(); + + for reference in evidence_references(&expected) { + let artifact_id = reference["artifactId"].as_str().expect("artifact id"); + let start = reference["startLine"].as_u64().expect("start line"); + let end = reference["endLine"].as_u64().expect("end line"); + assert!(start > 0 && start <= end, "{scenario}: {reference}"); + assert!( + end <= u64::try_from(*artifacts.get(artifact_id).expect("declared artifact")) + .expect("line count"), + "{scenario}: {reference}" + ); + } + } +} + +#[test] +fn preparation_matrix_covers_required_states_without_cross_side_claims() { + let expected = SCENARIOS + .iter() + .map(|scenario| ((*scenario).to_owned(), read_json(scenario, "expected.json"))) + .collect::>(); + assert_eq!( + expected["complete"]["transactions"][0]["state"], + "succeeded" + ); + assert_eq!( + expected["download-failure"]["transactions"][0]["classification"], + "confirmedFailure" + ); + assert_eq!( + expected["scheduler-deferred"]["transactions"][0]["state"], + "deferred" + ); + assert_eq!( + expected["gate-c-contradictory"]["transactions"][0]["state"], + "contradictory" + ); + assert_eq!( + expected["malformed"]["extractionProfile"]["selectionState"], + "unvalidatedVersion" + ); + assert!(expected["rotation-split"]["transactions"] + .as_array() + .expect("transactions") + .is_empty()); + for (scenario, expected) in expected { + assert_eq!( + expected["analysisContract"]["crossSideCorrelationPerformed"], false, + "{scenario}" + ); + assert_eq!( + expected["correlationHandoff"]["timeOnlyEligible"], false, + "{scenario}" + ); + assert_eq!( + expected["correlationHandoff"]["bundleCaptureHostUsedAsManagementPointEvidence"], false, + "{scenario}" + ); + } +} + +fn evidence_references(expected: &Value) -> Vec<&Value> { + let mut references = Vec::new(); + for collection in ["transactions", "sourceLocalObservations", "findings"] { + for item in expected[collection].as_array().into_iter().flatten() { + references.extend(item["evidence"].as_array().into_iter().flatten()); + } + } + references +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs new file mode 100644 index 000000000..76eccab68 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs @@ -0,0 +1,977 @@ +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::client::{ + admit_client_evidence, analyze_client_task_sequence, assess_client_intake, + SccmClientCapturedPayload, SccmClientIntakeArtifact, SccmClientIntakeBundle, + SccmClientIntakeCaptureGap, SccmTaskSequenceClassification, SccmTaskSequenceConfidence, + SccmTaskSequenceCoverageState, SccmTaskSequenceOrderingState, +}; +use cmtraceopen_parser::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const SCENARIOS: [&str; 17] = [ + "client-install-failure", + "client-installed", + "complete-looking-unkeyed", + "completed", + "disk-image-failure", + "incomplete", + "invalid-offset", + "post-format", + "pre-client", + "reboot-continuation", + "relocated-fragments", + "rotation-boundary", + "software-install-failure", + "terminal-preflight", + "unknown-profile", + "unrelated-runs", + "winpe", +]; + +fn fixture_root(scenario: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/client/task_sequence") + .join(scenario) +} + +fn read_json(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).expect("fixture JSON is readable")) + .expect("fixture JSON is valid") +} + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn opaque_artifact_id(value: &str) -> String { + format!("sccm-artifact:v1:sha256:{}", digest(value.as_bytes())) +} + +fn stable_opaque_id(prefix: &str, parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part.as_bytes()); + } + format!( + "{prefix}{}", + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} + +fn expected_transaction_id(transaction: &Value) -> String { + let key = &transaction["key"]; + stable_opaque_id( + "cmtraceopen.task-sequence.transaction.sha256.v1:", + &[ + key["executionId"].as_str().expect("executionId is present"), + key["taskSequencePackageId"] + .as_str() + .expect("package ID is present"), + key["advertisementId"] + .as_str() + .expect("advertisement ID is present"), + key["runContext"].as_str().expect("run context is present"), + ], + ) +} + +fn coverage(value: &str) -> SccmCoverageState { + match value { + "captured" => SccmCoverageState::Captured, + "absent" => SccmCoverageState::Absent, + other => panic!("unsupported Task Sequence fixture capture state: {other}"), + } +} + +fn rotation(value: &Value) -> SccmRotation { + match value["kind"].as_str().expect("rotation kind is a string") { + "current" => SccmRotation::Current, + "lo" => SccmRotation::LoUnderscore, + other => panic!("unsupported Task Sequence fixture rotation: {other}"), + } +} + +fn wire_rotation_kind(value: &Value) -> &str { + match value["kind"].as_str().expect("rotation kind is a string") { + "lo" => "loUnderscore", + "current" => "current", + other => panic!("unsupported Task Sequence fixture rotation: {other}"), + } +} + +fn safe_path_class(value: &str) -> &str { + match value { + "winpe" => "winpe", + "setup" => "setup", + "fullOs" => "full-os", + "client" => "client", + "unknown" => "unknown", + other => panic!("unsupported Task Sequence fixture path class: {other}"), + } +} + +fn intake_relative_path(artifact: &Value, display_name: &str, rotation: &SccmRotation) -> String { + let path_class = safe_path_class( + artifact["pathClass"] + .as_str() + .expect("pathClass is a string"), + ); + let storage_path = artifact["relativePath"].as_str().unwrap_or_default(); + let root = if storage_path.contains("/root-a/") { + Some("root-a") + } else if storage_path.contains("/root-b/") { + Some("root-b") + } else { + None + }; + let rotation_segment = match rotation { + SccmRotation::Current => "current", + SccmRotation::LoUnderscore => "lo", + _ => unreachable!("fixture rotation is bounded above"), + }; + + match root { + Some(root) => format!( + "evidence/client-task-sequence-smsts/{path_class}/{root}/{rotation_segment}/{display_name}" + ), + None => format!( + "evidence/client-task-sequence-smsts/{path_class}/{rotation_segment}/{display_name}" + ), + } +} + +fn admitted_scenario( + scenario: &str, +) -> cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence { + admitted_scenario_with_order(scenario, false) +} + +fn admitted_scenario_with_order( + scenario: &str, + reverse: bool, +) -> cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence { + let root = fixture_root(scenario); + let manifest = read_json(&root.join("manifest.json")); + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + + for fixture in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + let fixture_artifact_id = fixture["artifactId"] + .as_str() + .expect("artifactId is a string"); + let artifact_id = opaque_artifact_id(fixture_artifact_id); + let display_name = fixture["originalBasename"] + .as_str() + .expect("originalBasename is a string"); + let capture_state = fixture["captureState"] + .as_str() + .expect("captureState is a string"); + let rotation = rotation(&fixture["rotation"]); + let fragment_complete = Some( + fixture["rotation"]["fragmentComplete"] + .as_bool() + .unwrap_or(false), + ); + let relative_path = fixture["relativePath"] + .as_str() + .filter(|path| !path.is_empty()); + let bytes = relative_path.map(|path| { + std::fs::read(root.join(path)).expect("declared Task Sequence evidence is readable") + }); + let content_binding = bytes.as_ref().filter(|_| capture_state == "captured"); + let path_fingerprint = fixture["pathFingerprint"] + .as_str() + .map(|value| format!("sha256:{}", digest(value.as_bytes()))); + let rotation_lineage = fixture["sanitizedSourcePath"] + .as_str() + .and_then(|value| value.rsplit_once('/').map(|(parent, _)| parent)) + .map(|parent| { + format!( + "cmtraceopen.lineage.sha256.v1:{}", + digest(parent.as_bytes()) + ) + }); + + artifacts.push(SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: display_name.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: fixture["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: fixture["capturedUtc"].as_str().map(str::to_owned), + rotation: rotation.clone(), + coverage: coverage(capture_state), + encoding: fixture["encoding"].as_str().map(str::to_owned), + }, + path_fingerprint, + rotation_lineage, + relative_path: relative_path + .map(|_| intake_relative_path(fixture, display_name, &rotation)), + fragment_complete, + declared_byte_length: content_binding.map(|bytes| bytes.len() as u64), + content_sha256: content_binding.map(|bytes| digest(bytes)), + }); + + if let Some(bytes) = content_binding { + payloads.push( + SccmClientCapturedPayload::new(&artifact_id, bytes.clone()) + .expect("fixture payload identity is canonical"), + ); + } + } + + if reverse { + artifacts.reverse(); + payloads.reverse(); + } + + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle) + .unwrap_or_else(|error| panic!("{scenario}: Task Sequence intake is canonical: {error}")); + admit_client_evidence(&bundle, &assessment, &payloads) + .expect("Task Sequence evidence reaches the sealed admission boundary") +} + +fn admitted_custom_records( + label: &str, + content: &str, +) -> cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence { + let bytes = content.as_bytes().to_vec(); + let artifact_id = opaque_artifact_id(label); + let bundle = SccmClientIntakeBundle { + artifacts: vec![SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: "smsts.log".to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.TEST.0000".to_owned()), + collected_at_utc: Some("2026-07-30T02:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some(format!("sha256:{}", digest(label.as_bytes()))), + rotation_lineage: Some(format!( + "cmtraceopen.lineage.sha256.v1:{}", + digest(format!("{label}-lineage").as_bytes()) + )), + relative_path: Some( + "evidence/client-task-sequence-smsts/client/current/smsts.log".to_owned(), + ), + fragment_complete: Some(true), + declared_byte_length: Some(bytes.len() as u64), + content_sha256: Some(digest(&bytes)), + }], + capture_gaps: Vec::new(), + }; + let assessment = + assess_client_intake(&bundle).expect("custom Task Sequence intake is canonical"); + let payload = SccmClientCapturedPayload::new(&artifact_id, bytes) + .expect("custom payload identity is canonical"); + admit_client_evidence(&bundle, &assessment, &[payload]) + .expect("custom Task Sequence records are admitted") +} + +fn evidence_projection(value: &Value, make_opaque: bool) -> Vec<(String, u64, u64)> { + value + .as_array() + .expect("evidence is an array") + .iter() + .map(|reference| { + let artifact_id = reference["artifactId"] + .as_str() + .expect("artifactId is a string"); + ( + if make_opaque { + opaque_artifact_id(artifact_id) + } else { + artifact_id.to_owned() + }, + reference + .get("lineStart") + .or_else(|| reference.get("startLine")) + .expect("line start is present") + .as_u64() + .expect("lineStart is a number"), + reference + .get("lineEnd") + .or_else(|| reference.get("endLine")) + .expect("line end is present") + .as_u64() + .expect("lineEnd is a number"), + ) + }) + .collect() +} + +fn evidence_item_projection(value: &Value, make_opaque: bool) -> Option<(String, u64, u64)> { + (!value.is_null()).then(|| { + let artifact_id = value["artifactId"] + .as_str() + .expect("artifactId is a string"); + ( + if make_opaque { + opaque_artifact_id(artifact_id) + } else { + artifact_id.to_owned() + }, + value + .get("lineStart") + .or_else(|| value.get("startLine")) + .expect("line start is present") + .as_u64() + .expect("line start is numeric"), + value + .get("lineEnd") + .or_else(|| value.get("endLine")) + .expect("line end is present") + .as_u64() + .expect("line end is numeric"), + ) + }) +} + +#[test] +fn every_committed_scenario_runs_through_the_exported_production_reducer() { + for scenario in SCENARIOS { + let root = fixture_root(scenario); + let expected = read_json(&root.join("expected.json")); + let manifest = read_json(&root.join("manifest.json")); + let admitted = admitted_scenario(scenario); + let actual = serde_json::to_value( + analyze_client_task_sequence(&admitted) + .expect("sealed Task Sequence analysis succeeds"), + ) + .expect("Task Sequence analysis serializes"); + let actual_transactions = actual["transactions"] + .as_array() + .expect("production transactions are an array"); + let expected_transactions = expected["transactions"] + .as_array() + .expect("expected transactions are an array"); + + assert_eq!( + actual_transactions.len(), + expected_transactions.len(), + "{scenario}: transaction count" + ); + for expected_transaction in expected_transactions { + let transaction_id = expected_transaction_id(expected_transaction); + let actual_transaction = actual_transactions + .iter() + .find(|transaction| transaction["transactionId"] == transaction_id) + .unwrap_or_else(|| { + panic!("{scenario}: missing stable transaction {transaction_id}") + }); + for field in ["phase", "state", "classification"] { + assert_eq!( + actual_transaction[field], expected_transaction[field], + "{scenario}: transaction {field}" + ); + } + for field in ["lastSuccessfulPhase", "confidence"] { + assert_eq!( + actual_transaction[field], expected_transaction[field], + "{scenario}: transaction {field}" + ); + } + assert_eq!( + actual_transaction["transactionId"], transaction_id, + "{scenario}: subject-derived transaction ID" + ); + assert_eq!( + actual_transaction["orderingState"], + expected_transaction["timestampProvenance"]["orderingState"], + "{scenario}: ordering state" + ); + assert_eq!( + evidence_projection(&actual_transaction["evidence"], false), + evidence_projection(&expected_transaction["evidence"], true), + "{scenario}: exact transaction evidence" + ); + assert_eq!( + actual_transaction["identityProof"]["evidence"], actual_transaction["evidence"], + "{scenario}: every joined record independently proves the exact identity" + ); + assert_eq!( + actual_transaction["identityProof"]["extractionProfileId"], + expected["extractionProfile"]["id"], + "{scenario}: reviewed extraction profile" + ); + assert_eq!( + evidence_item_projection(&actual_transaction["terminalEvidence"], false), + evidence_item_projection(&expected_transaction["terminalEvidence"], true), + "{scenario}: terminal evidence" + ); + let actual_next = &actual_transaction["nextEvidence"]; + let expected_next = &expected_transaction["nextArtifact"]; + assert_eq!( + actual_next.is_null(), + expected_next.is_null(), + "{scenario}: bounded next-evidence presence" + ); + if !expected_next.is_null() { + for field in ["logicalArtifactId", "pathClass"] { + assert_eq!( + actual_next[field], expected_next[field], + "{scenario}: next-evidence {field}" + ); + } + assert!( + actual_next["reason"] + .as_str() + .is_some_and(|reason| !reason.is_empty()), + "{scenario}: next-evidence reason is bounded and nonempty" + ); + } + assert_eq!( + actual_transaction["pathSequence"] + .as_array() + .expect("pathSequence is an array") + .iter() + .map(|path| path["pathClass"].clone()) + .collect::>(), + expected_transaction["pathSequence"] + .as_array() + .expect("expected pathSequence is an array") + .iter() + .map(|path| path["pathClass"].clone()) + .collect::>(), + "{scenario}: admitted path progression" + ); + for path in actual_transaction["pathSequence"] + .as_array() + .expect("pathSequence is an array") + { + let fixture_id = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .find(|artifact| { + opaque_artifact_id( + artifact["artifactId"] + .as_str() + .expect("fixture artifact ID is a string"), + ) == path["artifactId"] + }) + .expect("path observation is owned by one physical artifact"); + assert_eq!( + path["rotation"]["kind"], + wire_rotation_kind(&fixture_id["rotation"]), + "{scenario}: physical rotation ownership" + ); + } + } + + let mut expected_coverage_gaps = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .filter(|artifact| { + artifact["captureState"] != "captured" + || artifact["rotation"]["fragmentComplete"] != true + }) + .map(|artifact| { + ( + opaque_artifact_id( + artifact["artifactId"] + .as_str() + .expect("artifact ID is a string"), + ), + if artifact["captureState"] == "captured" { + "partial".to_owned() + } else { + artifact["captureState"] + .as_str() + .expect("capture state is a string") + .to_owned() + }, + artifact["pathClass"] + .as_str() + .expect("path class is a string") + .to_owned(), + ) + }) + .collect::>(); + expected_coverage_gaps.sort(); + let mut actual_coverage_gaps = actual["coverageGaps"] + .as_array() + .expect("coverage gaps are an array") + .iter() + .map(|gap| { + ( + gap["artifactId"] + .as_str() + .expect("gap artifact ID is a string") + .to_owned(), + gap["coverage"] + .as_str() + .expect("gap coverage is a string") + .to_owned(), + gap["pathClass"] + .as_str() + .expect("gap path class is a string") + .to_owned(), + ) + }) + .collect::>(); + actual_coverage_gaps.sort(); + assert_eq!( + actual_coverage_gaps, expected_coverage_gaps, + "{scenario}: physical coverage gaps" + ); + + let actual_observations = actual["sourceLocalObservations"] + .as_array() + .expect("source-local observations are an array"); + let expected_observations = expected["sourceLocalObservations"] + .as_array() + .expect("expected source-local observations are an array"); + assert_eq!( + actual_observations.len(), + expected_observations.len(), + "{scenario}: source-local observation count" + ); + for expected_observation in expected_observations { + let expected_artifact_id = opaque_artifact_id( + expected_observation["artifactId"] + .as_str() + .expect("observation artifact ID is a string"), + ); + let actual_observation = actual_observations + .iter() + .find(|observation| observation["artifactId"] == expected_artifact_id) + .expect("source-local observation retains physical ownership"); + for field in [ + "keyConfidence", + "confidence", + "correlationEligible", + "phaseHint", + "stateHint", + ] { + assert_eq!( + actual_observation[field], expected_observation[field], + "{scenario}: source-local {field}" + ); + } + assert_eq!( + evidence_item_projection(&actual_observation["evidence"], false), + evidence_item_projection(&expected_observation["evidence"], true), + "{scenario}: source-local physical citation" + ); + let fixture_artifact = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .find(|artifact| artifact["artifactId"] == expected_observation["artifactId"]) + .expect("source-local observation has manifest provenance"); + assert_eq!( + actual_observation["rotation"]["kind"], + wire_rotation_kind(&fixture_artifact["rotation"]) + ); + assert_eq!( + actual_observation["coverage"], + fixture_artifact["captureState"] + ); + let evidence = expected_observation["evidence"] + .as_object() + .expect("source-local evidence is present"); + let line_start = evidence["startLine"] + .as_u64() + .expect("source-local start line is present") + .to_string(); + let line_end = evidence["endLine"] + .as_u64() + .expect("source-local end line is present") + .to_string(); + let mut observation_parts = vec![ + expected_artifact_id.as_str(), + line_start.as_str(), + line_end.as_str(), + ]; + if fixture_artifact["rotation"]["fragmentComplete"] == false { + observation_parts.push("physical-fragment"); + } + assert_eq!( + actual_observation["observationId"], + stable_opaque_id( + "cmtraceopen.task-sequence.observation.sha256.v1:", + &observation_parts, + ), + "{scenario}: stable source-local observation ID" + ); + } + + assert_eq!( + actual["findings"].as_array().map(Vec::len), + expected["findings"].as_array().map(Vec::len), + "{scenario}: finding count" + ); + let actual_findings = actual["findings"] + .as_array() + .expect("findings are an array"); + let expected_findings = expected["findings"] + .as_array() + .expect("expected findings are an array"); + for expected_finding in expected_findings { + let expected_evidence = evidence_projection(&expected_finding["evidence"], true); + let actual_finding = actual_findings + .iter() + .find(|finding| { + finding["classification"] == expected_finding["classification"] + && evidence_projection(&finding["evidence"], false) == expected_evidence + }) + .expect("expected finding is emitted with exact evidence"); + let expected_finding_id = + if let Some(transaction_id) = actual_finding["transactionId"].as_str() { + stable_opaque_id( + "cmtraceopen.task-sequence.finding.sha256.v1:", + &[transaction_id, "transaction"], + ) + } else if !actual_finding["coverageGaps"] + .as_array() + .expect("finding coverage gaps are an array") + .is_empty() + || expected_evidence.len() > 1 + { + let mut parts = vec!["coverage"]; + parts.extend( + actual["coverageGaps"] + .as_array() + .expect("analysis coverage gaps are an array") + .iter() + .map(|gap| { + gap["artifactId"] + .as_str() + .expect("gap artifact ID is present") + }), + ); + stable_opaque_id("cmtraceopen.task-sequence.finding.sha256.v1:", &parts) + } else { + let observation_id = actual_observations + .iter() + .find(|observation| { + evidence_item_projection(&observation["evidence"], false) + == expected_evidence.first().cloned() + }) + .and_then(|observation| observation["observationId"].as_str()) + .expect("source-local finding has its observation"); + stable_opaque_id( + "cmtraceopen.task-sequence.finding.sha256.v1:", + &["source-local", observation_id], + ) + }; + assert_eq!( + actual_finding["findingId"], expected_finding_id, + "{scenario}: stable finding ID" + ); + let mut actual_finding_gaps = actual_finding["coverageGaps"] + .as_array() + .expect("finding coverage gaps are an array") + .iter() + .map(|gap| { + gap["artifactId"] + .as_str() + .expect("finding gap artifact ID is present") + .to_owned() + }) + .collect::>(); + actual_finding_gaps.sort(); + let mut expected_finding_gaps = expected_finding["coverageGapArtifactIds"] + .as_array() + .expect("expected finding gap IDs are an array") + .iter() + .map(|artifact_id| { + opaque_artifact_id( + artifact_id + .as_str() + .expect("expected finding gap ID is a string"), + ) + }) + .collect::>(); + expected_finding_gaps.sort(); + assert_eq!( + actual_finding_gaps, expected_finding_gaps, + "{scenario}: finding coverage evidence" + ); + if let Some(transaction_id) = actual_finding["transactionId"].as_str() { + let transaction = actual_transactions + .iter() + .find(|transaction| transaction["transactionId"] == transaction_id) + .expect("transaction finding references its transaction"); + assert_eq!(actual_finding["confidence"], transaction["confidence"]); + assert_eq!(actual_finding["phase"], transaction["phase"]); + } else { + assert_eq!(actual_finding["confidence"], "low"); + assert!(actual_finding["phase"].is_null()); + } + assert_eq!( + actual_finding["nextEvidence"].is_null(), + expected_finding["boundedNextArtifact"].is_null(), + "{scenario}: finding next-evidence presence" + ); + if !expected_finding["boundedNextArtifact"].is_null() { + for field in ["logicalArtifactId", "pathClass"] { + assert_eq!( + actual_finding["nextEvidence"][field], + expected_finding["boundedNextArtifact"][field], + "{scenario}: finding next-evidence {field}" + ); + } + } + } + assert_eq!( + actual_findings + .iter() + .filter_map(|finding| finding["findingId"].as_str()) + .collect::>() + .len(), + actual_findings.len(), + "{scenario}: finding IDs are unique" + ); + } +} + +#[test] +fn exported_analysis_redacts_join_identity_paths_and_native_acceptance() { + let wire = SCENARIOS + .iter() + .map(|scenario| { + serde_json::to_string( + &analyze_client_task_sequence(&admitted_scenario(scenario)) + .expect("sealed Task Sequence analysis succeeds"), + ) + .expect("Task Sequence analysis serializes") + }) + .collect::(); + + assert!(!wire.contains("72400000")); + assert!(!wire.contains("LAB00324")); + assert!(!wire.contains("LAB203")); + assert!(!wire.contains("SYNTHETIC://")); + assert!(!wire.contains("_SMSTSLogPath")); + assert!(!wire.contains("nativeAcceptance")); +} + +#[test] +fn production_result_is_input_order_invariant() { + let admitted = admitted_scenario_with_order("relocated-fragments", false); + let first = serde_json::to_value( + analyze_client_task_sequence(&admitted).expect("first analysis succeeds"), + ) + .expect("first analysis serializes"); + let reversed = admitted_scenario_with_order("relocated-fragments", true); + let second = serde_json::to_value( + analyze_client_task_sequence(&reversed).expect("reversed analysis succeeds"), + ) + .expect("second analysis serializes"); + + assert_eq!(first, second); +} + +#[test] +fn same_execution_with_equal_timestamps_is_ambiguous_not_ordered() { + let admitted = admitted_custom_records( + "same-time-task-sequence", + concat!( + "\n", + "\n" + ), + ); + + let analysis = analyze_client_task_sequence(&admitted).expect("analysis succeeds"); + + assert_eq!(analysis.transactions.len(), 1); + assert_eq!( + analysis.transactions[0].ordering_state, + SccmTaskSequenceOrderingState::Ambiguous + ); + assert_eq!( + analysis.transactions[0].classification, + SccmTaskSequenceClassification::InsufficientEvidence + ); + assert_eq!( + analysis.transactions[0].confidence, + SccmTaskSequenceConfidence::Low + ); + assert!(analysis.transactions[0].terminal_evidence.is_none()); +} + +#[test] +fn transaction_ids_are_subject_derived_and_stable_across_result_sets() { + let run_a = "\n"; + let run_b = "\n"; + let analysis_a = analyze_client_task_sequence(&admitted_custom_records("stable-a", run_a)) + .expect("run A analysis succeeds"); + let analysis_b = analyze_client_task_sequence(&admitted_custom_records("stable-b", run_b)) + .expect("run B analysis succeeds"); + let combined = analyze_client_task_sequence(&admitted_custom_records( + "stable-combined", + &format!("{run_b}{run_a}"), + )) + .expect("combined analysis succeeds"); + + let id_a = &analysis_a.transactions[0].transaction_id; + let id_b = &analysis_b.transactions[0].transaction_id; + assert_ne!(id_a, id_b); + assert!(id_a.starts_with("cmtraceopen.task-sequence.transaction.sha256.v1:")); + assert_eq!( + combined + .transactions + .iter() + .map(|transaction| transaction.transaction_id.as_str()) + .collect::>(), + [id_a.as_str(), id_b.as_str()].into_iter().collect() + ); +} + +#[test] +fn terminal_failure_followed_by_success_requires_explicit_recovery_authority() { + let admitted = admitted_custom_records( + "ambiguous-recovery", + concat!( + "\n", + "\n" + ), + ); + + let analysis = analyze_client_task_sequence(&admitted).expect("analysis succeeds"); + let transaction = &analysis.transactions[0]; + assert_eq!( + transaction.classification, + SccmTaskSequenceClassification::InsufficientEvidence + ); + assert_eq!(transaction.confidence, SccmTaskSequenceConfidence::Low); + assert_eq!( + transaction.ordering_state, + SccmTaskSequenceOrderingState::Ambiguous + ); + assert!(transaction.terminal_evidence.is_none()); +} + +#[test] +fn mixed_invalid_chronology_is_ambiguous_without_a_terminal_citation() { + let admitted = admitted_custom_records( + "mixed-invalid-chronology", + concat!( + "\n", + "\n" + ), + ); + + let analysis = analyze_client_task_sequence(&admitted).expect("analysis succeeds"); + let transaction = &analysis.transactions[0]; + assert_eq!( + transaction.ordering_state, + SccmTaskSequenceOrderingState::Ambiguous + ); + assert_eq!( + transaction.classification, + SccmTaskSequenceClassification::InsufficientEvidence + ); + assert!(transaction.terminal_evidence.is_none()); +} + +#[test] +fn source_local_profile_and_rotation_gaps_remain_cited_and_noncorrelatable() { + let unknown = analyze_client_task_sequence(&admitted_scenario("unknown-profile")) + .expect("unknown profile analysis succeeds"); + assert_eq!(unknown.source_local_observations.len(), 1); + assert!(!unknown.source_local_observations[0].correlation_eligible); + assert!(unknown.source_local_observations[0].evidence.is_some()); + + let unkeyed = analyze_client_task_sequence(&admitted_scenario("complete-looking-unkeyed")) + .expect("unkeyed analysis succeeds"); + assert_eq!(unkeyed.source_local_observations.len(), 1); + assert!(!unkeyed.source_local_observations[0].correlation_eligible); + + let rotation = analyze_client_task_sequence(&admitted_scenario("rotation-boundary")) + .expect("rotation analysis succeeds"); + assert_eq!(rotation.source_local_observations.len(), 2); + assert!(rotation + .source_local_observations + .iter() + .all(|observation| !observation.correlation_eligible)); + assert!(rotation + .source_local_observations + .iter() + .all(|observation| observation.evidence.is_some())); +} + +#[test] +fn identity_fields_split_across_records_never_form_a_transaction() { + let admitted = admitted_custom_records( + "split-task-sequence-identity", + concat!( + "\n", + "\n" + ), + ); + + let analysis = analyze_client_task_sequence(&admitted).expect("analysis succeeds"); + + assert!(analysis.transactions.is_empty()); + assert_eq!(analysis.findings.len(), 2); +} + +#[test] +fn duplicate_record_local_identity_labels_are_not_exact_keys() { + let admitted = admitted_custom_records( + "duplicate-task-sequence-identity", + "\n", + ); + + let analysis = analyze_client_task_sequence(&admitted).expect("analysis succeeds"); + + assert!(analysis.transactions.is_empty()); + assert_eq!(analysis.source_local_observations.len(), 1); + assert!(!analysis.source_local_observations[0].correlation_eligible); +} + +#[test] +fn coverage_only_task_sequence_capture_gap_survives_sealed_analysis() { + let lineage_digest = digest(b"coverage-only-task-sequence-lineage"); + let bundle = SccmClientIntakeBundle { + artifacts: Vec::new(), + capture_gaps: vec![SccmClientIntakeCaptureGap { + artifact_id: opaque_artifact_id("coverage-only-task-sequence"), + basename: "smsts.log".to_owned(), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Capped, + path_fingerprint: format!("sha256:{}", digest(b"coverage-only-task-sequence-path")), + rotation_lineage: format!("cmtraceopen.lineage.sha256.v1:{lineage_digest}"), + }], + }; + let assessment = assess_client_intake(&bundle).expect("coverage-only intake is canonical"); + let admitted = admit_client_evidence(&bundle, &assessment, &[]) + .expect("coverage-only intake yields sealed authority"); + + let analysis = analyze_client_task_sequence(&admitted).expect("analysis succeeds"); + + assert!(analysis.transactions.is_empty()); + assert_eq!(analysis.coverage_gaps.len(), 1); + assert_eq!( + analysis.coverage_gaps[0].coverage, + SccmTaskSequenceCoverageState::Capped + ); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs new file mode 100644 index 000000000..ae22c3e55 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -0,0 +1,3486 @@ +use cmtraceopen_parser::{ + models::log_entry::LogFormat, + parser::ccm::parse_content, + sccm::{ + normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmEvidence, SccmRole, + SccmRotation, SccmTimeOrderingState, + }, +}; +use regex::Regex; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; + +const SCENARIOS: [&str; 17] = [ + "client-install-failure", + "client-installed", + "complete-looking-unkeyed", + "completed", + "disk-image-failure", + "incomplete", + "invalid-offset", + "post-format", + "pre-client", + "reboot-continuation", + "relocated-fragments", + "rotation-boundary", + "software-install-failure", + "terminal-preflight", + "unknown-profile", + "unrelated-runs", + "winpe", +]; + +const STATE_CHAIN: [&str; 8] = [ + "start", + "preflight", + "diskOrImage", + "setupWindows", + "installClient", + "installSoftware", + "postAction", + "complete", +]; + +const PATH_CLASSES: [&str; 5] = ["client", "fullOs", "setup", "unknown", "winpe"]; +const EXACT_KEY_JOIN_FIELDS: [&str; 4] = [ + "executionId", + "taskSequencePackageId", + "advertisementId", + "runContext", +]; +const FORBIDDEN_JOIN_FIELDS: [&str; 5] = + ["component", "displayName", "filename", "path", "timestamp"]; +const TRANSACTION_CORRELATION_SCOPES: [&str; 4] = [ + "clientOnly", + "clientOnlyOrderingUnknown", + "clientRelocationOnly", + "taskSequenceOnly", +]; +const EXPECTED_ARTIFACTS: usize = 22; +const EXPECTED_EVIDENCE_FILES: usize = 21; +const EXPECTED_EVIDENCE_BYTES: u64 = 8_243; +const EXPECTED_EVIDENCE_LINES: usize = 21; +const EXPECTED_CORPUS_DIGEST: &str = + "917df82bdf96ae4debd3e02e669669a9b564e932d7052091fb39094305593c8b"; + +const SHA256_ROUND_CONSTANTS: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +#[derive(Debug, PartialEq, Eq)] +struct CorpusInventory { + scenarios: usize, + artifacts: usize, + evidence_files: usize, + evidence_bytes: u64, + evidence_lines: usize, + capture_states: BTreeMap, + digest: String, +} + +fn task_sequence_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/task_sequence") +} + +fn read_json(path: &Path) -> Value { + let contents = std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("{} must be readable: {error}", path.display())); + serde_json::from_str(&contents) + .unwrap_or_else(|error| panic!("{} must contain valid JSON: {error}", path.display())) +} + +struct TemporaryScenario { + root: PathBuf, +} + +impl Drop for TemporaryScenario { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn copy_scenario_to_temporary_root(scenario: &str, mutation: &str) -> TemporaryScenario { + let source_root = task_sequence_root().join(scenario); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "cmtraceopen-sccm-324-{}-{nonce}-{mutation}", + std::process::id() + )); + for source in walk_files(&source_root) { + let relative = source + .strip_prefix(&source_root) + .expect("scenario file is below its root"); + let destination = root.join(relative); + std::fs::create_dir_all( + destination + .parent() + .expect("scenario file has a parent directory"), + ) + .expect("temporary scenario directory is created"); + std::fs::copy(&source, &destination).expect("scenario file is copied"); + } + TemporaryScenario { root } +} + +fn scenario_directories() -> Vec { + let mut scenarios = std::fs::read_dir(task_sequence_root()) + .expect("the #324 Task Sequence fixture root must exist") + .map(|entry| { + entry + .expect("Task Sequence fixture directory entry is readable") + .path() + }) + .filter(|path| path.is_dir()) + .map(|path| { + path.file_name() + .expect("scenario directory has a name") + .to_string_lossy() + .into_owned() + }) + .collect::>(); + scenarios.sort(); + scenarios +} + +fn walk_files(root: &Path) -> Vec { + if !root.exists() { + return Vec::new(); + } + + let mut pending = vec![root.to_path_buf()]; + let mut files = Vec::new(); + while let Some(path) = pending.pop() { + if path.is_dir() { + let mut children = std::fs::read_dir(&path) + .expect("fixture directory is readable") + .map(|entry| entry.expect("fixture entry is readable").path()) + .collect::>(); + children.sort(); + pending.extend(children.into_iter().rev()); + } else { + files.push(path); + } + } + files +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + let bit_length = (bytes.len() as u64) + .checked_mul(8) + .expect("fixture byte length fits SHA-256"); + let mut padded = bytes.to_vec(); + padded.push(0x80); + while padded.len() % 64 != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_length.to_be_bytes()); + + let mut state = [ + 0x6a09e667u32, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19, + ]; + + for chunk in padded.chunks_exact(64) { + let mut words = [0u32; 64]; + for (index, word) in words.iter_mut().take(16).enumerate() { + let offset = index * 4; + *word = u32::from_be_bytes([ + chunk[offset], + chunk[offset + 1], + chunk[offset + 2], + chunk[offset + 3], + ]); + } + for index in 16..64 { + let sigma0 = words[index - 15].rotate_right(7) + ^ words[index - 15].rotate_right(18) + ^ (words[index - 15] >> 3); + let sigma1 = words[index - 2].rotate_right(17) + ^ words[index - 2].rotate_right(19) + ^ (words[index - 2] >> 10); + words[index] = words[index - 16] + .wrapping_add(sigma0) + .wrapping_add(words[index - 7]) + .wrapping_add(sigma1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = state; + for index in 0..64 { + let sum1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let temporary1 = h + .wrapping_add(sum1) + .wrapping_add(choose) + .wrapping_add(SHA256_ROUND_CONSTANTS[index]) + .wrapping_add(words[index]); + let sum0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temporary2 = sum0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temporary1); + d = c; + c = b; + b = a; + a = temporary1.wrapping_add(temporary2); + } + + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + state[5] = state[5].wrapping_add(f); + state[6] = state[6].wrapping_add(g); + state[7] = state[7].wrapping_add(h); + } + + let mut digest = [0u8; 32]; + for (index, word) in state.iter().enumerate() { + digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + digest +} + +fn hex_digest(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn corpus_inventory() -> CorpusInventory { + let mut scenario_count = 0; + let mut artifacts = 0; + let mut evidence_files = 0; + let mut evidence_bytes = 0; + let mut evidence_lines = 0; + let mut capture_states = BTreeMap::new(); + let mut digest_rows = Vec::new(); + + for scenario in scenario_directories() { + scenario_count += 1; + let scenario_root = task_sequence_root().join(&scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + artifacts += 1; + let state = artifact["captureState"] + .as_str() + .expect("captureState is a string"); + *capture_states.entry(state.to_owned()).or_insert(0) += 1; + + let Some(relative_path) = artifact["relativePath"].as_str() else { + continue; + }; + let bytes = std::fs::read(scenario_root.join(relative_path)) + .expect("evidence bytes are readable"); + evidence_files += 1; + evidence_bytes += bytes.len() as u64; + evidence_lines += String::from_utf8(bytes.clone()) + .expect("evidence is UTF-8") + .lines() + .count(); + let artifact_id = artifact["artifactId"] + .as_str() + .expect("artifactId is a string"); + digest_rows.push(format!( + "{scenario}\0{artifact_id}\0{relative_path}\0{}\n", + hex_digest(&sha256(&bytes)) + )); + } + } + digest_rows.sort(); + + CorpusInventory { + scenarios: scenario_count, + artifacts, + evidence_files, + evidence_bytes, + evidence_lines, + capture_states, + digest: hex_digest(&sha256(digest_rows.concat().as_bytes())), + } +} + +/// An evidence reference is exactly the identity triple. Only these three +/// fields are read when a citation is resolved, so any extra key would let two +/// references that name one physical record compare as different citations. +fn evidence_reference_identity(value: &Value) -> Option<(&str, u64, u64)> { + let object = value.as_object()?; + Some(( + object.get("artifactId")?.as_str()?, + object.get("startLine")?.as_u64()?, + object.get("endLine")?.as_u64()?, + )) +} + +fn same_evidence_reference(left: &Value, right: &Value) -> bool { + match ( + evidence_reference_identity(left), + evidence_reference_identity(right), + ) { + (Some(left), Some(right)) => left == right, + _ => false, + } +} + +fn validate_evidence_reference_shapes(scenario: &str, value: &Value) -> Result<(), String> { + match value { + Value::Object(object) => { + if evidence_reference_identity(value).is_some() && object.len() != 3 { + let unmodeled = object + .keys() + .filter(|field| { + !matches!(field.as_str(), "artifactId" | "startLine" | "endLine") + }) + .cloned() + .collect::>(); + return Err(format!( + "{scenario}: evidence reference declares unmodeled fields {unmodeled:?}" + )); + } + for child in object.values() { + validate_evidence_reference_shapes(scenario, child)?; + } + Ok(()) + } + Value::Array(array) => array + .iter() + .try_for_each(|child| validate_evidence_reference_shapes(scenario, child)), + _ => Ok(()), + } +} + +fn collect_evidence_refs(value: &Value, refs: &mut Vec<(String, u64, u64)>) { + match value { + Value::Object(object) => { + if let (Some(artifact_id), Some(start_line), Some(end_line)) = ( + object.get("artifactId").and_then(Value::as_str), + object.get("startLine").and_then(Value::as_u64), + object.get("endLine").and_then(Value::as_u64), + ) { + refs.push((artifact_id.to_owned(), start_line, end_line)); + } + for child in object.values() { + collect_evidence_refs(child, refs); + } + } + Value::Array(array) => { + for child in array { + collect_evidence_refs(child, refs); + } + } + _ => {} + } +} + +fn string_array(value: &Value) -> Result, String> { + value + .as_array() + .ok_or_else(|| "value is not an array".to_owned())? + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| "array item is not a string".to_owned()) + }) + .collect() +} + +fn sorted_ids(value: &Value, field: &str) -> Vec { + value + .as_array() + .expect("value is an array") + .iter() + .map(|item| { + item[field] + .as_str() + .unwrap_or_else(|| panic!("{field} is a string")) + .to_owned() + }) + .collect() +} + +fn artifact_effective_state(artifact: &Value) -> Result { + let state = artifact["captureState"] + .as_str() + .ok_or_else(|| "artifact captureState is not a string".to_owned())?; + match state { + "captured" => { + let fragment_complete = artifact["rotation"]["fragmentComplete"] + .as_bool() + .ok_or_else(|| "captured artifact has no fragmentComplete flag".to_owned())?; + Ok(if fragment_complete { + "captured".to_owned() + } else { + "partial".to_owned() + }) + } + "capped" | "absent" | "accessDenied" | "skipped" | "unsupported" | "parseFailed" + | "unsafePath" => Ok(state.to_owned()), + other => Err(format!("unsupported captureState {other}")), + } +} + +fn smsts_log_paths(contents: &str) -> BTreeSet { + contents + .match_indices("_SMSTSLogPath=") + .filter_map(|(start, _)| { + let value = &contents[start + "_SMSTSLogPath=".len()..]; + let end = value + .find(|character: char| character.is_whitespace() || character == ']') + .unwrap_or(value.len()); + (end > 0).then(|| value[..end].to_owned()) + }) + .collect() +} + +/// Only whitespace and the record body edges bound a token: inside a CCM body a +/// bare bracket is a legal value character, and only the full `]LOG]!>` +/// sequence terminates the body. Returns `None` when the body cannot be +/// delimited, so an undelimitable citation fails closed instead of widening +/// admission to the `` trailer. +fn complete_field_tokens(record_text: &str) -> Option> { + let after_prefix = record_text.strip_prefix("")?; + Some(after_prefix[..terminator].split_whitespace().collect()) +} + +fn sanitized_basename(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +fn sanitized_parent(path: &str) -> &str { + path.rsplit_once('/').map_or(path, |(parent, _)| parent) +} + +fn path_class_for_sanitized_path(path: &str) -> Option<&'static str> { + [ + ("SYNTHETIC://client/", "client"), + ("SYNTHETIC://full-os/", "fullOs"), + ("SYNTHETIC://setup/", "setup"), + ("SYNTHETIC://unknown/", "unknown"), + ("SYNTHETIC://winpe/", "winpe"), + ] + .into_iter() + .find_map(|(prefix, path_class)| path.starts_with(prefix).then_some(path_class)) +} + +fn combine_coverage_states(states: &[String]) -> Result { + if states.iter().any(|state| state == "captured") { + return Ok("captured".to_owned()); + } + if states.iter().any(|state| state == "capped") { + return Ok("capped".to_owned()); + } + if states.iter().any(|state| state == "partial") { + return Ok("partial".to_owned()); + } + let distinct = states.iter().cloned().collect::>(); + if distinct.len() == 1 { + return Ok(distinct.into_iter().next().expect("one coverage state")); + } + Err(format!("ambiguous noncapture coverage states {distinct:?}")) +} + +fn evidence_text( + scenario_root: &Path, + artifacts_by_id: &BTreeMap<&str, &Value>, + evidence_ref: &Value, +) -> Result { + let artifact_id = evidence_ref["artifactId"] + .as_str() + .ok_or_else(|| "evidence reference has no artifactId".to_owned())?; + let artifact = artifacts_by_id + .get(artifact_id) + .ok_or_else(|| format!("unknown evidence artifact {artifact_id}"))?; + let relative_path = artifact["relativePath"] + .as_str() + .ok_or_else(|| format!("{artifact_id} has no captured evidence path"))?; + let contents = std::fs::read_to_string(scenario_root.join(relative_path)) + .map_err(|error| format!("{artifact_id} is unreadable: {error}"))?; + let lines = contents.lines().collect::>(); + let start = evidence_ref["startLine"] + .as_u64() + .ok_or_else(|| format!("{artifact_id} evidence has no startLine"))? + as usize; + let end = evidence_ref["endLine"] + .as_u64() + .ok_or_else(|| format!("{artifact_id} evidence has no endLine"))? as usize; + if start == 0 || end < start || end > lines.len() { + return Err(format!( + "{artifact_id} evidence lines {start}-{end}/{} are invalid", + lines.len() + )); + } + Ok(lines[start - 1..end].join("\n")) +} + +fn manifest_artifact<'a>( + artifacts_by_id: &'a BTreeMap<&str, &Value>, + evidence_ref: &Value, +) -> Result<&'a Value, String> { + let artifact_id = evidence_ref["artifactId"] + .as_str() + .ok_or_else(|| "evidence reference has no artifactId".to_owned())?; + artifacts_by_id + .get(artifact_id) + .copied() + .ok_or_else(|| format!("unknown evidence artifact {artifact_id}")) +} + +fn normalized_evidence( + scenario_root: &Path, + artifact: &Value, +) -> Result, String> { + let relative_path = artifact["relativePath"] + .as_str() + .ok_or_else(|| "artifact has no physical evidence path".to_owned())?; + let contents = std::fs::read_to_string(scenario_root.join(relative_path)) + .map_err(|error| format!("{relative_path} is unreadable: {error}"))?; + let rotation = match artifact["rotation"]["kind"].as_str() { + Some("current") => SccmRotation::Current, + Some("lo") => SccmRotation::LoUnderscore, + other => return Err(format!("unsupported test rotation {other:?}")), + }; + let source = SccmArtifact { + artifact_id: artifact["artifactId"] + .as_str() + .ok_or_else(|| "artifactId is not a string".to_owned())? + .to_owned(), + display_name: artifact["originalBasename"] + .as_str() + .ok_or_else(|| "originalBasename is not a string".to_owned())? + .to_owned(), + original_path: artifact["sanitizedSourcePath"].as_str().map(str::to_owned), + host: None, + role: SccmRole::Client, + configmgr_version: artifact["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: artifact["capturedUtc"].as_str().map(str::to_owned), + rotation, + coverage: SccmCoverageState::Captured, + encoding: artifact["encoding"].as_str().map(str::to_owned), + }; + Ok(normalize_ccm_artifact(source, &contents)) +} + +fn ordering_state_name(state: &SccmTimeOrderingState) -> &'static str { + match state { + SccmTimeOrderingState::NormalizedUtc => "normalizedUtc", + SccmTimeOrderingState::OffsetMissing => "offsetMissing", + SccmTimeOrderingState::OffsetInvalid => "offsetInvalid", + SccmTimeOrderingState::TimestampMissing => "timestampMissing", + } +} + +fn validate_manifest_and_storage( + scenario: &str, + scenario_root: &Path, + manifest: &Value, +) -> Result, String> { + if manifest["sccmManifestVersion"] != 1 + || manifest["scenario"] != scenario + || manifest["proposalOnly"] != true + || manifest["syntheticFixture"] != true + || manifest["bundle"]["role"] != "client" + || manifest["bundle"]["workflow"] != "taskSequence" + || manifest["bundle"]["siteCode"] != "LAB" + { + return Err(format!("{scenario}: manifest boundary metadata drifted")); + } + + let artifacts = manifest["artifacts"] + .as_array() + .ok_or_else(|| format!("{scenario}: artifacts are not an array"))?; + let mut artifact_ids = BTreeSet::new(); + let mut relative_paths = BTreeMap::new(); + let mut canonical_paths = BTreeSet::new(); + let mut referenced_files = BTreeSet::new(); + let mut logical_states = BTreeMap::>::new(); + let mut logical_paths = BTreeMap::>::new(); + + for artifact in artifacts { + let artifact_id = artifact["artifactId"] + .as_str() + .ok_or_else(|| format!("{scenario}: artifactId is not a string"))?; + if !artifact_ids.insert(artifact_id) { + return Err(format!("{scenario}: duplicate artifactId {artifact_id}")); + } + if artifact["role"] != "client" || artifact["kind"] != "ccmLog" { + return Err(format!( + "{scenario}/{artifact_id}: Task Sequence artifacts stay client CCM evidence" + )); + } + let logical_id = artifact["designOnlyCatalog"]["entryId"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: missing design-only entry ID"))?; + if logical_id != "client-task-sequence-smsts" { + return Err(format!( + "{scenario}/{artifact_id}: unexpected logical source {logical_id}" + )); + } + if string_array(&artifact["designOnlyCatalog"]["groupMemberships"])? + != ["client-task-sequence-smsts"] + { + return Err(format!( + "{scenario}/{artifact_id}: design-only group membership drifted" + )); + } + let path_fingerprint = artifact["pathFingerprint"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: pathFingerprint is missing"))?; + if !path_fingerprint.starts_with("synthetic:") { + return Err(format!( + "{scenario}/{artifact_id}: pathFingerprint is not synthetic" + )); + } + let path_class = artifact["pathClass"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: pathClass is not a string"))?; + if !PATH_CLASSES.contains(&path_class) { + return Err(format!( + "{scenario}/{artifact_id}: unsupported pathClass {path_class}" + )); + } + logical_paths + .entry(logical_id.to_owned()) + .or_default() + .insert(path_class.to_owned()); + logical_states + .entry(logical_id.to_owned()) + .or_default() + .push(artifact_effective_state(artifact)?); + + let original_basename = artifact["originalBasename"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: originalBasename is missing"))?; + let rotation_kind = artifact["rotation"]["kind"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: rotation kind is missing"))?; + if !matches!( + (original_basename, rotation_kind), + ("smsts.log", "current") | ("smsts.lo_", "lo") + ) { + return Err(format!( + "{scenario}/{artifact_id}: noncanonical basename/rotation {original_basename}/{rotation_kind}" + )); + } + + let state = artifact["captureState"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: captureState is missing"))?; + if state == "captured" { + if artifact["encoding"] != "utf-8" { + return Err(format!("{scenario}/{artifact_id}: captured encoding")); + } + if artifact["collectionLimit"]["byteLimit"] != 4096 + || artifact["collectionLimit"]["limitApplied"] != false + || !artifact["sourceVersion"].is_string() + || !artifact["capturedUtc"].is_string() + { + return Err(format!( + "{scenario}/{artifact_id}: captured provenance metadata drifted" + )); + } + let captured_utc = artifact["capturedUtc"] + .as_str() + .expect("capturedUtc was checked as a string"); + if chrono::DateTime::parse_from_rfc3339(captured_utc).is_err() { + return Err(format!( + "{scenario}/{artifact_id}: capturedUtc is not RFC 3339" + )); + } + let relative_path = artifact["relativePath"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: captured path is missing"))?; + if let Some(previous) = relative_paths.insert(relative_path, artifact_id) { + return Err(format!( + "{scenario}: duplicate evidence path {relative_path} aliases {previous} and {artifact_id}" + )); + } + let relative = Path::new(relative_path); + if relative.is_absolute() + || !relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + || relative.components().next() + != Some(Component::Normal(std::ffi::OsStr::new("evidence"))) + { + return Err(format!( + "{scenario}/{artifact_id}: unsafe relativePath {relative_path}" + )); + } + let fixture_path = scenario_root.join(relative); + if !fixture_path.is_file() { + return Err(format!( + "{scenario}/{artifact_id}: missing {}", + fixture_path.display() + )); + } + let canonical = fixture_path + .canonicalize() + .map_err(|error| format!("{relative_path} cannot canonicalize: {error}"))?; + if !canonical_paths.insert(canonical.clone()) { + return Err(format!( + "{scenario}/{artifact_id}: duplicate canonical evidence path" + )); + } + referenced_files.insert(canonical); + let bytes = std::fs::metadata(&fixture_path) + .map_err(|error| format!("{relative_path} metadata: {error}"))? + .len(); + if artifact["bytesCopied"].as_u64() != Some(bytes) { + return Err(format!( + "{scenario}/{artifact_id}: bytesCopied does not match {bytes}" + )); + } + let sanitized_path = artifact["sanitizedSourcePath"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: no sanitized source path"))?; + if !sanitized_path.starts_with("SYNTHETIC://") { + return Err(format!( + "{scenario}/{artifact_id}: sanitized source path is not synthetic" + )); + } + if path_class_for_sanitized_path(sanitized_path) != Some(path_class) { + return Err(format!( + "{scenario}/{artifact_id}: pathClass is not bound to sanitized capture provenance" + )); + } + if sanitized_basename(sanitized_path) != original_basename { + return Err(format!( + "{scenario}/{artifact_id}: capture source path does not name the {original_basename} physical file" + )); + } + let contents = std::fs::read_to_string(&fixture_path) + .map_err(|error| format!("{relative_path} is not UTF-8: {error}"))?; + let (entries, errors) = parse_content(&contents, relative_path, None); + let normalized = normalized_evidence(scenario_root, artifact)?; + let has_complete_ccm = errors == 0 + && !normalized.is_empty() + && entries.iter().any(|entry| entry.format == LogFormat::Ccm); + let fragment_complete = artifact["rotation"]["fragmentComplete"] + .as_bool() + .ok_or_else(|| { + format!("{scenario}/{artifact_id}: fragmentComplete is not a Boolean") + })?; + if fragment_complete != has_complete_ccm { + return Err(format!( + "{scenario}/{artifact_id}: fragmentComplete is not bound to physical CCM grammar" + )); + } + let observed_paths = smsts_log_paths(&contents); + let declared_path = if artifact["smstsLogPathEvidence"].is_null() { + None + } else { + Some( + artifact["smstsLogPathEvidence"] + .as_str() + .ok_or_else(|| { + format!( + "{scenario}/{artifact_id}: smstsLogPathEvidence is neither a string nor null" + ) + })?, + ) + }; + // Capture provenance and the in-record observation are separate: a + // rotated fragment is captured from smsts.lo_ while the record it + // physically contains still names the active log. The declared + // observation must be present in these bytes; it is never taken + // from the capture path. + match declared_path { + Some(declared_path) + if observed_paths.len() == 1 + && observed_paths.contains(declared_path) + && sanitized_parent(declared_path) == sanitized_parent(sanitized_path) => {} + Some(_) => { + return Err(format!( + "{scenario}/{artifact_id}: _SMSTSLogPath is not observed in this physical artifact" + )); + } + None if observed_paths.is_empty() => {} + None => { + return Err(format!( + "{scenario}/{artifact_id}: physical _SMSTSLogPath presence/absence is not declared exactly" + )); + } + } + } else if artifact["relativePath"].is_string() + || artifact["sanitizedSourcePath"].is_string() + || artifact["smstsLogPathEvidence"].is_string() + || artifact["encoding"].is_string() + || !artifact["collectionLimit"].is_null() + || artifact["bytesCopied"] != 0 + { + return Err(format!( + "{scenario}/{artifact_id}: noncapture artifact invents physical provenance" + )); + } else if !artifact["rotation"]["fragmentComplete"].is_null() { + return Err(format!( + "{scenario}/{artifact_id}: noncapture artifact declares physical fragment completeness" + )); + } else if path_class != "unknown" { + return Err(format!( + "{scenario}/{artifact_id}: noncapture pathClass must remain unknown" + )); + } + } + + let actual_files = walk_files(&scenario_root.join("evidence")) + .into_iter() + .map(|path| { + path.canonicalize() + .map_err(|error| format!("{} cannot canonicalize: {error}", path.display())) + }) + .collect::, _>>()?; + if actual_files != referenced_files { + return Err(format!( + "{scenario}: physical evidence must be referenced exactly once" + )); + } + + logical_states + .into_iter() + .map(|(logical_id, states)| { + combine_coverage_states(&states).map(|state| (logical_id, state)) + }) + .collect() +} + +fn validate_contract( + scenario: &str, + scenario_root: &Path, + manifest: &Value, + expected: &Value, +) -> Result<(), String> { + let derived_coverage = validate_manifest_and_storage(scenario, scenario_root, manifest)?; + validate_evidence_reference_shapes(scenario, expected)?; + if expected["contractState"] != "proposedPending318And319" + || expected["workflow"] != "taskSequence" + || expected["scenario"] != scenario + || string_array(&expected["stateChain"])? != STATE_CHAIN.map(str::to_owned) + || expected["analysisContract"]["independentReducer"] != true + || expected["analysisContract"]["consumesAppOrPolicyReducerOutput"] != false + || expected["analysisContract"]["crossSideCorrelationPerformed"] != false + || expected["analysisContract"]["nativeAcceptanceClaimed"] != false + || expected["reorderedInputDeterministic"] != true + { + return Err(format!("{scenario}: expected boundary metadata drifted")); + } + + let artifacts = manifest["artifacts"] + .as_array() + .ok_or_else(|| "manifest artifacts are not an array".to_owned())?; + let artifacts_by_id = artifacts + .iter() + .map(|artifact| { + artifact["artifactId"] + .as_str() + .map(|artifact_id| (artifact_id, artifact)) + .ok_or_else(|| "artifactId is not a string".to_owned()) + }) + .collect::, _>>()?; + + let captured_artifacts = artifacts + .iter() + .filter(|artifact| artifact["captureState"] == "captured") + .collect::>(); + let reviewed_profile_matches = !captured_artifacts.is_empty() + && captured_artifacts.iter().all(|artifact| { + artifact["sourceVersion"] == "5.00.TEST.0000" && artifact["pathClass"] != "unknown" + }); + let (derived_profile_id, derived_profile_status) = if captured_artifacts.is_empty() { + (None, "notObserved") + } else if reviewed_profile_matches { + let has_partial_fragment = captured_artifacts + .iter() + .any(|artifact| artifact["rotation"]["fragmentComplete"] == false); + ( + Some("task-sequence-client-5.00.test-v1"), + if has_partial_fragment { + "matchedAfterControlledJoinOnly" + } else { + "matched" + }, + ) + } else { + (None, "unknownVersionRejected") + }; + let extraction_profile_id = expected["extractionProfile"]["id"].as_str(); + let extraction_profile_status = expected["extractionProfile"]["status"].as_str(); + if extraction_profile_id != derived_profile_id + || extraction_profile_status != Some(derived_profile_status) + { + return Err(format!( + "{scenario}: extraction profile is not bound to sourceVersion/pathClass evidence" + )); + } + + let mut declared_coverage = BTreeMap::new(); + for coverage in expected["coverage"] + .as_array() + .ok_or_else(|| "expected coverage is not an array".to_owned())? + { + let logical_id = coverage["logicalArtifactId"] + .as_str() + .ok_or_else(|| "coverage logicalArtifactId is not a string".to_owned())?; + let state = coverage["state"] + .as_str() + .ok_or_else(|| format!("{logical_id}: coverage state is not a string"))?; + if declared_coverage + .insert(logical_id.to_owned(), state.to_owned()) + .is_some() + { + return Err(format!("duplicate coverage row {logical_id}")); + } + + let mut declared_path_classes = string_array(&coverage["pathClasses"])?; + declared_path_classes.sort(); + declared_path_classes.dedup(); + let mut derived_path_classes = artifacts + .iter() + .filter(|artifact| artifact["designOnlyCatalog"]["entryId"] == logical_id) + .filter_map(|artifact| artifact["pathClass"].as_str().map(str::to_owned)) + .collect::>(); + derived_path_classes.sort(); + derived_path_classes.dedup(); + if declared_path_classes != derived_path_classes { + return Err(format!( + "{logical_id}: declared path classes {declared_path_classes:?} != {derived_path_classes:?}" + )); + } + if state == "partial" { + let mut declared_ids = string_array(&coverage["artifactIds"])?; + declared_ids.sort(); + let mut derived_ids = artifacts + .iter() + .filter(|artifact| { + artifact["designOnlyCatalog"]["entryId"] == logical_id + && artifact["captureState"] == "captured" + && artifact["rotation"]["fragmentComplete"] == false + }) + .filter_map(|artifact| artifact["artifactId"].as_str().map(str::to_owned)) + .collect::>(); + derived_ids.sort(); + if declared_ids != derived_ids { + return Err(format!( + "{logical_id}: partial artifact IDs {declared_ids:?} != {derived_ids:?}" + )); + } + } + } + if declared_coverage != derived_coverage { + return Err(format!( + "coverage mismatch: declared {declared_coverage:?}, derived {derived_coverage:?}" + )); + } + + let provenance = expected["artifactProvenance"] + .as_array() + .ok_or_else(|| "artifactProvenance is not an array".to_owned())?; + let mut provenance_ids = provenance + .iter() + .map(|item| { + item["artifactId"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| "provenance artifactId is not a string".to_owned()) + }) + .collect::, _>>()?; + let original_provenance_ids = provenance_ids.clone(); + provenance_ids.sort(); + let mut physical_ids = artifacts + .iter() + .filter(|artifact| artifact["relativePath"].is_string()) + .filter_map(|artifact| artifact["artifactId"].as_str().map(str::to_owned)) + .collect::>(); + physical_ids.sort(); + if original_provenance_ids != provenance_ids || provenance_ids != physical_ids { + return Err(format!( + "{scenario}: provenance must deterministically cover every physical artifact" + )); + } + for item in provenance { + let artifact_id = item["artifactId"] + .as_str() + .ok_or_else(|| "provenance artifactId is not a string".to_owned())?; + let artifact = artifacts_by_id + .get(artifact_id) + .ok_or_else(|| format!("unknown provenance artifact {artifact_id}"))?; + for field in [ + "bytesCopied", + "pathClass", + "sanitizedSourcePath", + "smstsLogPathEvidence", + ] { + if item[field] != artifact[field] { + return Err(format!( + "{scenario}/{artifact_id}: provenance field {field} drifted" + )); + } + } + if item["rotationKind"] != artifact["rotation"]["kind"] + || item["fragmentComplete"] != artifact["rotation"]["fragmentComplete"] + || item["relocationOrdinal"] != artifact["relocationOrdinal"] + { + return Err(format!( + "{scenario}/{artifact_id}: rotation/relocation provenance drifted" + )); + } + } + + let logical_reconstructions = expected + .as_object() + .ok_or_else(|| "expected contract is not an object".to_owned())? + .get("logicalReconstructions") + .map(|value| { + value + .as_array() + .ok_or_else(|| "logicalReconstructions is not an array".to_owned()) + }) + .transpose()? + .map(Vec::as_slice) + .unwrap_or(&[]); + let reconstruction_ids = logical_reconstructions + .iter() + .map(|reconstruction| { + reconstruction["reconstructionId"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| "logical reconstruction ID is not a string".to_owned()) + }) + .collect::, _>>()?; + let mut sorted_reconstruction_ids = reconstruction_ids.clone(); + sorted_reconstruction_ids.sort(); + if reconstruction_ids != sorted_reconstruction_ids + || reconstruction_ids.iter().collect::>().len() != reconstruction_ids.len() + { + return Err(format!( + "{scenario}: logical reconstruction IDs must be unique and sorted" + )); + } + + let incomplete_fragments_missing_path_evidence = artifacts + .iter() + .filter(|artifact| { + artifact["captureState"] == "captured" + && artifact["rotation"]["fragmentComplete"] == false + && artifact["smstsLogPathEvidence"].is_null() + }) + .map(|artifact| { + artifact["artifactId"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| "artifactId is not a string".to_owned()) + }) + .collect::, _>>()?; + let mut reconstructed_missing_path_evidence = BTreeSet::new(); + let mut reconstructed_artifacts = BTreeSet::new(); + for reconstruction in logical_reconstructions { + let reconstruction_id = reconstruction["reconstructionId"] + .as_str() + .expect("reconstruction IDs were checked as strings"); + let logical_artifact_id = reconstruction["logicalArtifactId"] + .as_str() + .ok_or_else(|| format!("{reconstruction_id}: logicalArtifactId is not a string"))?; + let ordered_ids = string_array(&reconstruction["orderedArtifactIds"])?; + if ordered_ids.len() != 2 { + return Err(format!( + "{reconstruction_id}: controlled rotation must name exactly lo then current" + )); + } + let lo_id = &ordered_ids[0]; + let current_id = &ordered_ids[1]; + if !reconstructed_artifacts.insert(lo_id.clone()) + || !reconstructed_artifacts.insert(current_id.clone()) + { + return Err(format!( + "{reconstruction_id}: physical fragment is reconstructed more than once" + )); + } + let lo = artifacts_by_id + .get(lo_id.as_str()) + .ok_or_else(|| format!("{reconstruction_id}: unknown lo artifact {lo_id}"))?; + let current = artifacts_by_id + .get(current_id.as_str()) + .ok_or_else(|| format!("{reconstruction_id}: unknown current artifact {current_id}"))?; + let sanitized_path = reconstruction["sanitizedSourcePath"] + .as_str() + .ok_or_else(|| format!("{reconstruction_id}: sanitizedSourcePath is missing"))?; + let path_class = reconstruction["pathClass"] + .as_str() + .ok_or_else(|| format!("{reconstruction_id}: pathClass is missing"))?; + let path_fingerprint = reconstruction["pathFingerprint"] + .as_str() + .ok_or_else(|| format!("{reconstruction_id}: pathFingerprint is missing"))?; + if logical_artifact_id != "client-task-sequence-smsts" + || reconstruction["coverageState"] != "partial" + || reconstruction["confidence"] != "low" + || reconstruction["correlationEligible"] != false + || lo["captureState"] != "captured" + || current["captureState"] != "captured" + || lo["rotation"]["kind"] != "lo" + || current["rotation"]["kind"] != "current" + || lo["rotation"]["fragmentComplete"] != false + || current["rotation"]["fragmentComplete"] != false + || lo["pathFingerprint"] != path_fingerprint + || current["pathFingerprint"] != path_fingerprint + || lo["sanitizedSourcePath"].as_str().map(sanitized_parent) + != Some(sanitized_parent(sanitized_path)) + || current["sanitizedSourcePath"] != sanitized_path + || lo["pathClass"] != path_class + || current["pathClass"] != path_class + || lo["sourceVersion"] != current["sourceVersion"] + || lo["relocationOrdinal"] != current["relocationOrdinal"] + || lo["smstsLogPathEvidence"] != sanitized_path + || !current["smstsLogPathEvidence"].is_null() + || derived_coverage + .get(logical_artifact_id) + .map(String::as_str) + != Some("partial") + || expected["correlationBoundary"]["scope"] != "sourceLocalOnly" + || !string_array(&expected["correlationBoundary"]["joinFields"])?.is_empty() + || string_array(&expected["correlationBoundary"]["rotationOrder"])? + != ["lo".to_owned(), "current".to_owned()] + || !expected["transactions"] + .as_array() + .is_some_and(Vec::is_empty) + { + return Err(format!( + "{reconstruction_id}: controlled lo-to-current reconstruction metadata is invalid" + )); + } + reconstructed_missing_path_evidence.insert(current_id.clone()); + let source_local_artifact_ids = expected["sourceLocalObservations"] + .as_array() + .ok_or_else(|| format!("{reconstruction_id}: sourceLocalObservations is missing"))? + .iter() + .filter_map(|observation| observation["artifactId"].as_str()) + .collect::>(); + if ![lo_id.as_str(), current_id.as_str()] + .into_iter() + .all(|artifact_id| source_local_artifact_ids.contains(artifact_id)) + { + return Err(format!( + "{reconstruction_id}: both physical fragments must remain source-local observations" + )); + } + + let path_evidence = &reconstruction["smstsLogPathEvidence"]; + if path_evidence["artifactId"] != lo_id.as_str() { + return Err(format!( + "{reconstruction_id}: logical path evidence must cite the lo fragment" + )); + } + let path_evidence_text = evidence_text(scenario_root, &artifacts_by_id, path_evidence)?; + let observed_paths = smsts_log_paths(&path_evidence_text); + if observed_paths.len() != 1 || !observed_paths.contains(sanitized_path) { + return Err(format!( + "{reconstruction_id}: logical path citation does not contain the declared _SMSTSLogPath" + )); + } + + let mut joined_contents = String::new(); + for artifact in [*lo, *current] { + let relative_path = artifact["relativePath"] + .as_str() + .ok_or_else(|| format!("{reconstruction_id}: fragment path is missing"))?; + joined_contents.push_str( + &std::fs::read_to_string(scenario_root.join(relative_path)) + .map_err(|error| format!("{reconstruction_id}/{relative_path}: {error}"))?, + ); + } + let (joined_entries, joined_errors) = parse_content( + &joined_contents, + "controlled-logical-reconstruction.log", + None, + ); + if joined_errors != 0 + || joined_entries.len() != 1 + || joined_entries[0].format != LogFormat::Ccm + { + return Err(format!( + "{reconstruction_id}: ordered physical fragments do not form exactly one CCM record" + )); + } + } + if reconstructed_missing_path_evidence != incomplete_fragments_missing_path_evidence { + return Err(format!( + "{scenario}: every incomplete fragment missing _SMSTSLogPath must have one explicit logical reconstruction" + )); + } + + let transactions = expected["transactions"] + .as_array() + .ok_or_else(|| "transactions are not an array".to_owned())?; + let transaction_ids = sorted_ids(&expected["transactions"], "transactionId"); + let mut sorted_transaction_ids = transaction_ids.clone(); + sorted_transaction_ids.sort(); + if transaction_ids != sorted_transaction_ids + || transaction_ids.iter().collect::>().len() != transaction_ids.len() + { + return Err(format!( + "{scenario}: transaction IDs must be unique and sorted" + )); + } + + let declared_scope = expected["correlationBoundary"]["scope"] + .as_str() + .ok_or_else(|| format!("{scenario}: correlation scope is not a string"))?; + let mut declared_join_fields = string_array(&expected["correlationBoundary"]["joinFields"])?; + declared_join_fields.sort(); + let mut declared_forbidden_fields = + string_array(&expected["correlationBoundary"]["forbiddenJoinFields"])?; + declared_forbidden_fields.sort(); + if declared_forbidden_fields != FORBIDDEN_JOIN_FIELDS.map(str::to_owned) { + return Err(format!( + "{scenario}: declared forbidden join fields do not match the enforced list" + )); + } + if transactions.is_empty() { + let enforced_scope = if expected["sourceLocalObservations"] + .as_array() + .is_some_and(|observations| !observations.is_empty()) + { + "sourceLocalOnly" + } else { + "coverageOnly" + }; + if declared_scope != enforced_scope { + return Err(format!( + "{scenario}: correlation scope exceeds source-local enforcement" + )); + } + if !declared_join_fields.is_empty() { + return Err(format!( + "{scenario}: an unkeyed scenario cannot declare join fields" + )); + } + } else { + if !TRANSACTION_CORRELATION_SCOPES.contains(&declared_scope) { + return Err(format!( + "{scenario}: correlation scope is not an enforced client-side scope" + )); + } + let mut enforced_join_fields = EXACT_KEY_JOIN_FIELDS.map(str::to_owned); + enforced_join_fields.sort(); + if declared_join_fields != enforced_join_fields { + return Err(format!( + "{scenario}: declared join fields do not match the enforced exact key fields" + )); + } + } + + for transaction in transactions { + let transaction_id = transaction["transactionId"] + .as_str() + .ok_or_else(|| "transactionId is not a string".to_owned())?; + let key = transaction["key"] + .as_object() + .ok_or_else(|| format!("{transaction_id}: key is not an object"))?; + for required in EXACT_KEY_JOIN_FIELDS { + if !key.get(required).is_some_and(Value::is_string) { + return Err(format!( + "{transaction_id}: missing exact key field {required}" + )); + } + } + for forbidden in FORBIDDEN_JOIN_FIELDS { + if key.contains_key(forbidden) { + return Err(format!( + "{transaction_id}: forbidden join field {forbidden}" + )); + } + } + if key.get("keyProfileKind").and_then(Value::as_str) + != Some("executionPackageAdvertisementContext") + { + return Err(format!( + "{transaction_id}: keyProfileKind is not the reviewed task sequence profile" + )); + } + if key.get("confidence").and_then(Value::as_str) != Some("exact") + || key.get("extractionProfileId").and_then(Value::as_str) != extraction_profile_id + || extraction_profile_id.is_none() + { + return Err(format!( + "{transaction_id}: exact key is not profile-qualified" + )); + } + + let evidence_refs = transaction["evidence"] + .as_array() + .ok_or_else(|| format!("{transaction_id}: evidence is not an array"))?; + let key_needles = key + .iter() + .filter(|(field, _)| { + !matches!( + field.as_str(), + "keyProfileKind" | "confidence" | "extractionProfileId" + ) + }) + .map(|(field, value)| { + value + .as_str() + .map(|value| format!("{field}={value}")) + .ok_or_else(|| format!("{transaction_id}: key {field} is not a string")) + }) + .collect::, _>>()?; + let mut cited_record_token_sets = Vec::new(); + for evidence_ref in evidence_refs { + let artifact = manifest_artifact(&artifacts_by_id, evidence_ref)?; + let start_line = evidence_ref["startLine"] + .as_u64() + .ok_or_else(|| format!("{transaction_id}: evidence startLine is missing"))? + as u32; + let end_line = evidence_ref["endLine"] + .as_u64() + .ok_or_else(|| format!("{transaction_id}: evidence endLine is missing"))? + as u32; + let normalized = normalized_evidence(scenario_root, artifact)?; + if !normalized.iter().any(|item| { + item.reference.line_start == Some(start_line) + && item.reference.line_end == Some(end_line) + }) { + return Err(format!( + "{transaction_id}: cited evidence is not one complete CCM record" + )); + } + + let record_text = evidence_text(scenario_root, &artifacts_by_id, evidence_ref)?; + let record_tokens = complete_field_tokens(&record_text).ok_or_else(|| { + format!("{transaction_id}: cited record body is not delimited by the CCM framing") + })?; + if let Some(missing_needle) = key_needles + .iter() + .find(|needle| !record_tokens.contains(needle.as_str())) + { + return Err(format!( + "{transaction_id}: declared key fields do not co-occur as complete tokens in cited complete CCM record ({missing_needle})" + )); + } + cited_record_token_sets.push( + record_tokens + .into_iter() + .map(str::to_owned) + .collect::>(), + ); + } + + let phase = transaction["phase"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: phase is not a string"))?; + let state = transaction["state"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: state is not a string"))?; + let last_successful_phase = transaction["lastSuccessfulPhase"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: lastSuccessfulPhase is not a string"))?; + if !STATE_CHAIN.contains(&phase) + || !STATE_CHAIN.contains(&last_successful_phase) + || !["inProgress", "blockedOrDeferred", "failed", "succeeded"].contains(&state) + || !cited_record_token_sets.iter().any(|record_tokens| { + record_tokens.contains(&format!("phase={phase}")) + && record_tokens.contains(&format!("state={state}")) + }) + { + return Err(format!( + "{transaction_id}: phase/state semantics are not bound to cited evidence" + )); + } + let admissible_last_successful_phase = if state == "succeeded" { + phase + } else { + let phase_index = STATE_CHAIN + .iter() + .position(|candidate| candidate == &phase) + .expect("phase membership was validated"); + phase_index + .checked_sub(1) + .map(|index| STATE_CHAIN[index]) + .ok_or_else(|| { + format!( + "{transaction_id}: lastSuccessfulPhase has no admissible observed predecessor" + ) + })? + }; + if last_successful_phase != admissible_last_successful_phase { + return Err(format!( + "{transaction_id}: lastSuccessfulPhase is not the admissible observed phase" + )); + } + + let path_items = transaction["pathSequence"] + .as_array() + .ok_or_else(|| format!("{transaction_id}: pathSequence is not an array"))?; + let mut declared_path_sequence = Vec::new(); + let mut evidence_path_sequence = Vec::new(); + let mut path_artifact_ids = BTreeSet::new(); + for path_item in path_items { + let artifact_id = path_item["artifactId"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: path artifactId is missing"))?; + if !path_artifact_ids.insert(artifact_id) { + return Err(format!( + "{transaction_id}: pathSequence repeats artifact {artifact_id}" + )); + } + let artifact = artifacts_by_id + .get(artifact_id) + .ok_or_else(|| format!("{transaction_id}: unknown path artifact {artifact_id}"))?; + let evidence_ref = evidence_refs + .iter() + .find(|evidence_ref| evidence_ref["artifactId"] == artifact_id) + .ok_or_else(|| { + format!( + "{transaction_id}: path artifact {artifact_id} is not key-bound cited evidence" + ) + })?; + if path_item["pathClass"] != artifact["pathClass"] + || path_item["relocationOrdinal"] != artifact["relocationOrdinal"] + { + return Err(format!( + "{transaction_id}: path provenance does not match {artifact_id}" + )); + } + declared_path_sequence.push(( + path_item["relocationOrdinal"] + .as_u64() + .ok_or_else(|| format!("{transaction_id}: relocationOrdinal is missing"))?, + artifact_id.to_owned(), + )); + let start_line = evidence_ref["startLine"] + .as_u64() + .ok_or_else(|| format!("{transaction_id}: path evidence startLine is missing"))? + as u32; + let end_line = evidence_ref["endLine"] + .as_u64() + .ok_or_else(|| format!("{transaction_id}: path evidence endLine is missing"))? + as u32; + let normalized = normalized_evidence(scenario_root, artifact)?; + let evidence = normalized + .iter() + .find(|item| { + item.reference.line_start == Some(start_line) + && item.reference.line_end == Some(end_line) + }) + .ok_or_else(|| { + format!( + "{transaction_id}: path citation for {artifact_id} is not one complete CCM record" + ) + })?; + evidence_path_sequence.push((evidence.timestamp.utc_millis, artifact_id.to_owned())); + } + let declared_artifact_order = declared_path_sequence + .iter() + .map(|(_, artifact_id)| artifact_id.as_str()) + .collect::>(); + let evidence_artifact_order = evidence_refs + .iter() + .map(|evidence_ref| { + evidence_ref["artifactId"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: evidence artifactId is missing")) + }) + .collect::, _>>()?; + if evidence_artifact_order != declared_artifact_order { + return Err(format!( + "{transaction_id}: evidence order must be unique and match canonical path sequence" + )); + } + let relocation_ordinals = declared_path_sequence + .iter() + .map(|(ordinal, _)| *ordinal) + .collect::>(); + if relocation_ordinals != (0..declared_path_sequence.len() as u64).collect::>() { + return Err(format!( + "{transaction_id}: relocation ordinals are not contiguous evidence order" + )); + } + if evidence_path_sequence.len() > 1 { + let mut derived_order = evidence_path_sequence + .into_iter() + .map(|(utc_millis, artifact_id)| { + utc_millis + .map(|utc_millis| (utc_millis, artifact_id)) + .ok_or_else(|| { + format!( + "{transaction_id}: relocation order lacks normalized timestamp evidence" + ) + }) + }) + .collect::, _>>()?; + derived_order.sort_by_key(|(utc_millis, _)| *utc_millis); + if derived_order.windows(2).any(|pair| pair[0].0 == pair[1].0) { + return Err(format!( + "{transaction_id}: relocation order is ambiguous at equal cited timestamps" + )); + } + let derived_artifact_order = derived_order + .iter() + .map(|(_, artifact_id)| artifact_id.as_str()) + .collect::>(); + if declared_artifact_order != derived_artifact_order { + return Err(format!( + "{transaction_id}: relocation order is not derived from cited evidence" + )); + } + } + + let timestamp = &transaction["timestampProvenance"]; + let ordering_ref = &transaction["orderingEvidence"]; + if !evidence_refs + .iter() + .any(|evidence_ref| same_evidence_reference(evidence_ref, ordering_ref)) + { + return Err(format!( + "{transaction_id}: ordering evidence is not key-bound transaction evidence" + )); + } + let artifact = manifest_artifact(&artifacts_by_id, ordering_ref)?; + let normalized = normalized_evidence(scenario_root, artifact)?; + let start_line = ordering_ref["startLine"] + .as_u64() + .ok_or_else(|| format!("{transaction_id}: ordering startLine is missing"))? + as u32; + let end_line = ordering_ref["endLine"] + .as_u64() + .ok_or_else(|| format!("{transaction_id}: ordering endLine is missing"))? + as u32; + let evidence = normalized + .iter() + .find(|item| { + item.reference.line_start == Some(start_line) + && item.reference.line_end == Some(end_line) + }) + .ok_or_else(|| { + format!("{transaction_id}: ordering citation is not one complete CCM record") + })?; + if timestamp["orderingState"].as_str() + != Some(ordering_state_name(&evidence.timestamp.ordering_state)) + || timestamp["offsetMinutes"].as_i64() + != evidence.timestamp.offset_minutes.map(i64::from) + { + return Err(format!( + "{transaction_id}: timestamp ordering/offset is not bound" + )); + } + let declared_utc = timestamp["normalizedUtc"].as_str().map(str::to_owned); + let parsed_utc = evidence.timestamp.utc_millis.map(|millis| { + chrono::DateTime::from_timestamp_millis(millis) + .expect("fixture timestamp is representable") + .to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true) + }); + if declared_utc != parsed_utc { + return Err(format!( + "{transaction_id}: normalized timestamp is not bound ({declared_utc:?} != {parsed_utc:?})" + )); + } + + for artifact_id in string_array(&transaction["coverageGapArtifactIds"])? { + let artifact = artifacts_by_id + .get(artifact_id.as_str()) + .ok_or_else(|| format!("{transaction_id}: unknown coverage gap {artifact_id}"))?; + if artifact_effective_state(artifact)? == "captured" { + return Err(format!( + "{transaction_id}: complete artifact {artifact_id} is a coverage gap" + )); + } + } + if !transaction["nextArtifact"].is_null() { + let next_artifact = transaction["nextArtifact"] + .as_object() + .ok_or_else(|| format!("{transaction_id}: next artifact is not an object"))?; + if next_artifact["logicalArtifactId"] != "client-task-sequence-smsts" + || !PATH_CLASSES.contains( + &next_artifact["pathClass"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: next pathClass is missing"))?, + ) + || !next_artifact["reason"].is_string() + { + return Err(format!( + "{transaction_id}: next artifact request is not bounded" + )); + } + } + + let classification = transaction["classification"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: classification is not a string"))?; + let confidence = transaction["confidence"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: confidence is not a string"))?; + let confidence_ceiling = transaction["confidenceCeiling"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: confidenceCeiling is not a string"))?; + if confidence != confidence_ceiling || !matches!(confidence, "low" | "medium" | "high") { + return Err(format!( + "{transaction_id}: confidence exceeds or does not match its ceiling" + )); + } + let ordering_state = timestamp["orderingState"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: orderingState is not a string"))?; + if ordering_state != "normalizedUtc" && confidence != "low" { + return Err(format!( + "{transaction_id}: non-normalized timestamp cannot exceed Low confidence" + )); + } + + let terminal_state = match classification { + "success" + if phase == "complete" + && state == "succeeded" + && last_successful_phase == "complete" + && confidence == "high" => + { + Some("succeeded") + } + "confirmedFailure" if state == "failed" && confidence == "high" => Some("failed"), + "blockedOrDeferred" + if state == "blockedOrDeferred" + && matches!(confidence, "low" | "medium") + && transaction["terminalEvidence"].is_null() => + { + None + } + "insufficientEvidence" + if state == "inProgress" + && matches!(confidence, "low" | "medium") + && transaction["terminalEvidence"].is_null() => + { + None + } + _ => { + return Err(format!( + "{transaction_id}: classification/state/confidence semantics are invalid" + )); + } + }; + if let Some(terminal_state) = terminal_state { + if transaction["terminalEvidence"].is_null() { + return Err(format!( + "{transaction_id}: terminal outcome lacks terminal evidence" + )); + } + if !evidence_refs.iter().any(|evidence_ref| { + same_evidence_reference(evidence_ref, &transaction["terminalEvidence"]) + }) { + return Err(format!( + "{transaction_id}: terminal evidence is not key-bound transaction evidence" + )); + } + let terminal_text = evidence_text( + scenario_root, + &artifacts_by_id, + &transaction["terminalEvidence"], + )?; + let terminal_tokens = complete_field_tokens(&terminal_text).ok_or_else(|| { + format!( + "{transaction_id}: terminal record body is not delimited by the CCM framing" + ) + })?; + if !terminal_tokens.contains("terminal=true") + || !terminal_tokens.contains(format!("state={terminal_state}").as_str()) + || !terminal_tokens.contains(format!("phase={phase}").as_str()) + { + return Err(format!( + "{transaction_id}: terminal citation does not prove the terminal outcome" + )); + } + } + } + + let observations = expected["sourceLocalObservations"] + .as_array() + .ok_or_else(|| "sourceLocalObservations is not an array".to_owned())?; + let observation_ids = sorted_ids(&expected["sourceLocalObservations"], "observationId"); + let mut sorted_observation_ids = observation_ids.clone(); + sorted_observation_ids.sort(); + if observation_ids != sorted_observation_ids + || observation_ids.iter().collect::>().len() != observation_ids.len() + { + return Err(format!( + "{scenario}: source-local observation IDs must be unique and sorted" + )); + } + for observation in observations { + let observation_id = observation["observationId"] + .as_str() + .ok_or_else(|| "source-local observation has no ID".to_owned())?; + if !matches!( + observation["keyConfidence"].as_str(), + Some("none" | "candidate") + ) || observation["confidence"] != "low" + || observation["confidenceCeiling"] != "low" + || observation["correlationEligible"] != false + { + return Err(format!( + "{observation_id}: source-local observation must stay Low and non-correlatable" + )); + } + let artifact_id = observation["artifactId"] + .as_str() + .ok_or_else(|| format!("{observation_id}: artifactId is missing"))?; + if observation["evidence"]["artifactId"] != artifact_id { + return Err(format!("{observation_id}: citation changed artifact")); + } + // The ordering and terminal disjuncts are redundant today, because both + // must already be members of their transaction's evidence, but they are + // kept so this rule stays correct if that membership requirement moves. + let cites_keyed_transaction_evidence = transactions.iter().any(|transaction| { + transaction["evidence"] + .as_array() + .is_some_and(|transaction_evidence| { + transaction_evidence.iter().any(|transaction_ref| { + same_evidence_reference(transaction_ref, &observation["evidence"]) + }) + }) + || same_evidence_reference( + &transaction["orderingEvidence"], + &observation["evidence"], + ) + || same_evidence_reference( + &transaction["terminalEvidence"], + &observation["evidence"], + ) + }); + if cites_keyed_transaction_evidence { + return Err(format!( + "{observation_id}: keyed transaction evidence cannot double as a source-local observation" + )); + } + evidence_text(scenario_root, &artifacts_by_id, &observation["evidence"])?; + } + + let finding_ids = sorted_ids(&expected["findings"], "findingId"); + let mut sorted_finding_ids = finding_ids.clone(); + sorted_finding_ids.sort(); + if finding_ids != sorted_finding_ids + || finding_ids.iter().collect::>().len() != finding_ids.len() + { + return Err(format!("{scenario}: finding IDs must be unique and sorted")); + } + for finding in expected["findings"] + .as_array() + .ok_or_else(|| "findings are not an array".to_owned())? + { + let finding_id = finding["findingId"] + .as_str() + .ok_or_else(|| "findingId is not a string".to_owned())?; + let finding_object = finding + .as_object() + .ok_or_else(|| format!("{finding_id}: finding is not an object"))?; + if finding_object + .keys() + .any(|field| field.to_ascii_lowercase().contains("notasksequence")) + { + return Err(format!( + "{finding_id}: absent coverage cannot become a no-run claim" + )); + } + let evidence = finding["evidence"] + .as_array() + .ok_or_else(|| format!("{finding_id}: evidence is not an array"))?; + let coverage_gaps = string_array(&finding["coverageGapArtifactIds"])?; + if evidence.is_empty() && coverage_gaps.is_empty() { + return Err(format!( + "{finding_id}: finding has neither evidence nor coverage" + )); + } + for evidence_ref in evidence { + evidence_text(scenario_root, &artifacts_by_id, evidence_ref)?; + } + for artifact_id in &coverage_gaps { + let artifact = artifacts_by_id + .get(artifact_id.as_str()) + .ok_or_else(|| format!("{finding_id}: unknown coverage gap {artifact_id}"))?; + if artifact_effective_state(artifact)? == "captured" { + return Err(format!( + "{finding_id}: complete artifact {artifact_id} is a coverage gap" + )); + } + } + if !finding["boundedNextArtifact"].is_null() { + let next_artifact = finding["boundedNextArtifact"] + .as_object() + .ok_or_else(|| format!("{finding_id}: next artifact is not an object"))?; + if next_artifact["logicalArtifactId"] != "client-task-sequence-smsts" + || !PATH_CLASSES.contains( + &next_artifact["pathClass"] + .as_str() + .ok_or_else(|| format!("{finding_id}: next pathClass is missing"))?, + ) + || !next_artifact["reason"].is_string() + { + return Err(format!( + "{finding_id}: next artifact request is not bounded" + )); + } + } + let classification = finding["classification"] + .as_str() + .ok_or_else(|| format!("{finding_id}: classification is not a string"))?; + let binding_transactions = transactions + .iter() + .filter(|transaction| { + transaction["classification"] == classification + && !evidence.is_empty() + && transaction["evidence"] + .as_array() + .is_some_and(|transaction_evidence| { + evidence.iter().all(|evidence_ref| { + transaction_evidence.iter().any(|transaction_ref| { + same_evidence_reference(transaction_ref, evidence_ref) + }) + }) + }) + }) + .collect::>(); + let cited_refs_are_source_local = !evidence.is_empty() + && evidence.iter().all(|evidence_ref| { + observations.iter().any(|observation| { + same_evidence_reference(&observation["evidence"], evidence_ref) + }) + }); + let outcome_is_transaction_bound = match classification { + "success" | "confirmedFailure" => { + matches!(binding_transactions.as_slice(), [transaction] if { + !transaction["terminalEvidence"].is_null() + && evidence + .iter() + .any(|evidence_ref| same_evidence_reference(evidence_ref, &transaction["terminalEvidence"])) + }) + } + "blockedOrDeferred" => binding_transactions.len() == 1, + "insufficientEvidence" => { + binding_transactions.len() == 1 + || (binding_transactions.is_empty() + && (evidence.is_empty() || cited_refs_are_source_local)) + } + _ => false, + }; + if !outcome_is_transaction_bound { + return Err(format!( + "{finding_id}: finding outcome is not bound to exactly one keyed transaction or its source-local citations" + )); + } + if finding["serverCauseClaimed"] != false + || finding["appOrPolicyCauseClaimed"] != false + || finding["nativeAcceptanceClaimed"] != false + { + return Err(format!("{finding_id}: prohibited cause/acceptance claim")); + } + } + + let mut refs = Vec::new(); + collect_evidence_refs(expected, &mut refs); + for (artifact_id, start_line, end_line) in refs { + let artifact = artifacts_by_id + .get(artifact_id.as_str()) + .ok_or_else(|| format!("{scenario}: unknown evidence artifact {artifact_id}"))?; + let relative_path = artifact["relativePath"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: citation is not physical"))?; + let line_count = std::fs::read_to_string(scenario_root.join(relative_path)) + .map_err(|error| format!("{relative_path}: {error}"))? + .lines() + .count() as u64; + if start_line == 0 || end_line < start_line || end_line > line_count { + return Err(format!( + "{scenario}/{artifact_id}: invalid evidence lines {start_line}-{end_line}/{line_count}" + )); + } + } + + Ok(()) +} + +#[test] +fn source_path_execution_and_phase_contract_is_pinned() { + assert_eq!( + scenario_directories(), + SCENARIOS.map(str::to_owned), + "the #324 preparation scenario matrix changed" + ); + + for scenario in SCENARIOS { + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let expected = read_json(&scenario_root.join("expected.json")); + validate_contract(scenario, &scenario_root, &manifest, &expected) + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + + let expected_path_classes = match scenario { + "client-install-failure" | "pre-client" => "fullOs", + "client-installed" + | "complete-looking-unkeyed" + | "completed" + | "invalid-offset" + | "reboot-continuation" + | "rotation-boundary" + | "software-install-failure" + | "unrelated-runs" => "client", + "disk-image-failure" | "post-format" => "setup", + "incomplete" | "unknown-profile" => "unknown", + "relocated-fragments" => "client,fullOs,setup,winpe", + "terminal-preflight" | "winpe" => "winpe", + _ => unreachable!("SCENARIOS is exhaustive"), + }; + assert_eq!( + string_array(&expected["coverage"][0]["pathClasses"]) + .expect("pathClasses are strings") + .join(","), + expected_path_classes, + "{scenario}: exact path-class matrix" + ); + + let (profile_id, profile_status) = match scenario { + "incomplete" => (None, "notObserved"), + "unknown-profile" => (None, "unknownVersionRejected"), + "rotation-boundary" => ( + Some("task-sequence-client-5.00.test-v1"), + "matchedAfterControlledJoinOnly", + ), + _ => (Some("task-sequence-client-5.00.test-v1"), "matched"), + }; + assert_eq!( + expected["extractionProfile"]["id"].as_str(), + profile_id, + "{scenario}: profile ID" + ); + assert_eq!( + expected["extractionProfile"]["status"].as_str(), + Some(profile_status), + "{scenario}: profile status" + ); + } +} + +#[test] +fn corpus_inventory_digest_bytes_lines_and_states_are_pinned() { + assert_eq!( + hex_digest(&sha256(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "test-only SHA-256 implementation must match the standard vector" + ); + let mut capture_states = BTreeMap::new(); + capture_states.insert("absent".to_owned(), 1); + capture_states.insert("captured".to_owned(), 21); + assert_eq!( + corpus_inventory(), + CorpusInventory { + scenarios: 17, + artifacts: EXPECTED_ARTIFACTS, + evidence_files: EXPECTED_EVIDENCE_FILES, + evidence_bytes: EXPECTED_EVIDENCE_BYTES, + evidence_lines: EXPECTED_EVIDENCE_LINES, + capture_states, + digest: EXPECTED_CORPUS_DIGEST.to_owned(), + } + ); +} + +#[test] +fn complete_and_incomplete_ccm_records_and_rotation_are_pinned() { + for scenario in SCENARIOS { + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + let Some(relative_path) = artifact["relativePath"].as_str() else { + continue; + }; + let contents = std::fs::read_to_string(scenario_root.join(relative_path)) + .expect("Task Sequence evidence is UTF-8"); + let normalized = + normalized_evidence(&scenario_root, artifact).expect("CCM evidence normalizes"); + let (entries, errors) = parse_content(&contents, relative_path, None); + if artifact["rotation"]["fragmentComplete"] == true { + assert_eq!(errors, 0, "{scenario}/{relative_path}: CCM errors"); + assert!( + !normalized.is_empty() + && !entries.is_empty() + && entries.iter().all(|entry| entry.format == LogFormat::Ccm), + "{scenario}/{relative_path}: complete artifact must contain logical CCM records" + ); + } else { + assert!( + normalized.is_empty() + && entries.iter().all(|entry| entry.format != LogFormat::Ccm), + "{scenario}/{relative_path}: physical fragment formed a logical CCM record" + ); + } + } + } + + let rotation_root = task_sequence_root().join("rotation-boundary"); + let manifest = read_json(&rotation_root.join("manifest.json")); + let artifacts = manifest["artifacts"] + .as_array() + .expect("rotation artifacts are an array"); + let archived = artifacts + .iter() + .find(|artifact| artifact["rotation"]["kind"] == "lo") + .expect("archived rotation artifact"); + let current = artifacts + .iter() + .find(|artifact| artifact["rotation"]["kind"] == "current") + .expect("current rotation artifact"); + assert_eq!(archived["originalBasename"], "smsts.lo_"); + assert_eq!(current["originalBasename"], "smsts.log"); + assert_eq!(archived["pathFingerprint"], current["pathFingerprint"]); + assert_ne!(archived["relativePath"], current["relativePath"]); + assert_eq!(archived["rotation"]["fragmentComplete"], false); + assert_eq!(current["rotation"]["fragmentComplete"], false); + + let archived_text = std::fs::read_to_string( + rotation_root.join( + archived["relativePath"] + .as_str() + .expect("archived relative path"), + ), + ) + .expect("archived fragment is readable"); + let current_text = std::fs::read_to_string( + rotation_root.join( + current["relativePath"] + .as_str() + .expect("current relative path"), + ), + ) + .expect("current fragment is readable"); + let (joined_entries, joined_errors) = parse_content( + &format!("{archived_text}{current_text}"), + "test-only-join.log", + None, + ); + assert_eq!(joined_errors, 0); + assert_eq!(joined_entries.len(), 1); + assert_eq!(joined_entries[0].format, LogFormat::Ccm); +} + +#[test] +fn relocation_order_and_same_time_execution_separation_are_explicit() { + let relocated = read_json( + &task_sequence_root() + .join("relocated-fragments") + .join("expected.json"), + ); + let transaction = &relocated["transactions"][0]; + let path_classes = transaction["pathSequence"] + .as_array() + .expect("pathSequence is an array") + .iter() + .map(|item| item["pathClass"].as_str().expect("pathClass is a string")) + .collect::>(); + assert_eq!(path_classes, ["winpe", "setup", "fullOs", "client"]); + assert_eq!(transaction["phase"], "complete"); + assert_eq!(transaction["state"], "succeeded"); + + let unrelated = read_json( + &task_sequence_root() + .join("unrelated-runs") + .join("expected.json"), + ); + let transactions = unrelated["transactions"] + .as_array() + .expect("transactions are an array"); + assert_eq!(transactions.len(), 2); + assert_ne!( + transactions[0]["key"]["executionId"], + transactions[1]["key"]["executionId"] + ); + assert_eq!( + transactions[0]["timestampProvenance"]["normalizedUtc"], + transactions[1]["timestampProvenance"]["normalizedUtc"], + "same-time adversarial executions must be pinned" + ); + let first_evidence = transactions[0]["evidence"][0]["artifactId"] + .as_str() + .expect("first evidence artifact ID"); + let second_evidence = transactions[1]["evidence"][0]["artifactId"] + .as_str() + .expect("second evidence artifact ID"); + assert_ne!(first_evidence, second_evidence); +} + +#[test] +fn terminal_deferred_and_unkeyed_semantics_remain_conservative() { + let phase_matrix = [ + ( + "client-install-failure", + 0, + "installClient", + "failed", + "setupWindows", + "confirmedFailure", + true, + ), + ( + "client-installed", + 0, + "installClient", + "inProgress", + "setupWindows", + "insufficientEvidence", + false, + ), + ( + "completed", + 0, + "complete", + "succeeded", + "complete", + "success", + true, + ), + ( + "disk-image-failure", + 0, + "diskOrImage", + "failed", + "preflight", + "confirmedFailure", + true, + ), + ( + "invalid-offset", + 0, + "installSoftware", + "inProgress", + "installClient", + "insufficientEvidence", + false, + ), + ( + "post-format", + 0, + "diskOrImage", + "inProgress", + "preflight", + "insufficientEvidence", + false, + ), + ( + "pre-client", + 0, + "setupWindows", + "blockedOrDeferred", + "diskOrImage", + "blockedOrDeferred", + false, + ), + ( + "reboot-continuation", + 0, + "postAction", + "blockedOrDeferred", + "installSoftware", + "blockedOrDeferred", + false, + ), + ( + "relocated-fragments", + 0, + "complete", + "succeeded", + "complete", + "success", + true, + ), + ( + "software-install-failure", + 0, + "installSoftware", + "failed", + "installClient", + "confirmedFailure", + true, + ), + ( + "terminal-preflight", + 0, + "preflight", + "failed", + "start", + "confirmedFailure", + true, + ), + ( + "unrelated-runs", + 0, + "installSoftware", + "inProgress", + "installClient", + "insufficientEvidence", + false, + ), + ( + "unrelated-runs", + 1, + "preflight", + "inProgress", + "start", + "insufficientEvidence", + false, + ), + ( + "winpe", + 0, + "preflight", + "inProgress", + "start", + "insufficientEvidence", + false, + ), + ]; + for ( + scenario, + transaction_index, + phase, + state, + last_successful_phase, + classification, + has_terminal_evidence, + ) in phase_matrix + { + let expected = read_json(&task_sequence_root().join(scenario).join("expected.json")); + let transaction = &expected["transactions"][transaction_index]; + assert_eq!(transaction["phase"], phase, "{scenario}: phase"); + assert_eq!(transaction["state"], state, "{scenario}: state"); + assert_eq!( + transaction["lastSuccessfulPhase"], last_successful_phase, + "{scenario}: last successful phase" + ); + assert_eq!( + transaction["classification"], classification, + "{scenario}: classification" + ); + assert_eq!( + !transaction["terminalEvidence"].is_null(), + has_terminal_evidence, + "{scenario}: terminality" + ); + } + + let terminal_cases = [ + ("terminal-preflight", "preflight"), + ("disk-image-failure", "diskOrImage"), + ("client-install-failure", "installClient"), + ("software-install-failure", "installSoftware"), + ]; + for (scenario, phase) in terminal_cases { + let expected = read_json(&task_sequence_root().join(scenario).join("expected.json")); + let transaction = &expected["transactions"][0]; + assert_eq!(transaction["phase"], phase, "{scenario}: phase"); + assert_eq!(transaction["state"], "failed", "{scenario}: state"); + assert_eq!( + transaction["classification"], "confirmedFailure", + "{scenario}: classification" + ); + assert!( + !transaction["terminalEvidence"].is_null(), + "{scenario}: terminal evidence" + ); + } + + let reboot = read_json( + &task_sequence_root() + .join("reboot-continuation") + .join("expected.json"), + ); + assert_eq!( + reboot["transactions"][0]["classification"], + "blockedOrDeferred" + ); + assert_ne!(reboot["transactions"][0]["state"], "failed"); + + for scenario in [ + "rotation-boundary", + "unknown-profile", + "complete-looking-unkeyed", + ] { + let expected = read_json(&task_sequence_root().join(scenario).join("expected.json")); + assert!( + expected["transactions"] + .as_array() + .expect("transactions are an array") + .is_empty(), + "{scenario}: unvalidated evidence cannot create a transaction" + ); + let observations = expected["sourceLocalObservations"] + .as_array() + .expect("sourceLocalObservations is an array"); + assert!( + !observations.is_empty(), + "{scenario}: source-local retention" + ); + assert!( + observations.iter().all(|observation| { + observation["confidenceCeiling"] == "low" + && observation["correlationEligible"] == false + }), + "{scenario}: Low/non-correlatable ceiling" + ); + } +} + +#[test] +fn missing_smsts_is_coverage_not_a_no_run_claim() { + let scenario_root = task_sequence_root().join("incomplete"); + assert!( + walk_files(&scenario_root.join("evidence")).is_empty(), + "all-noncapture scenario has an empty physical evidence corpus" + ); + let expected = read_json(&scenario_root.join("expected.json")); + assert_eq!(expected["coverage"][0]["state"], "absent"); + assert!(expected["transactions"] + .as_array() + .expect("transactions are an array") + .is_empty()); + assert_eq!( + expected["findings"][0]["classification"], + "insufficientEvidence" + ); + let serialized = serde_json::to_string(&expected).expect("expected JSON serializes"); + assert!(!serialized.contains("noTaskSequenceRan")); + assert!(!serialized.contains("noTaskSequence")); +} + +#[test] +fn fixture_privacy_and_scope_boundaries_are_pinned() { + let profile_path = + Regex::new(r"(?i)\b[A-Z]:\\{1,2}(?:Users|Windows|_SMSTaskSequence)\\{1,2}").unwrap(); + assert!(profile_path.is_match(r"C:\Windows\synthetic.log")); + assert!(profile_path.is_match(r"C:\\Windows\\synthetic.log")); + assert!(!profile_path.is_match("SYNTHETIC://winpe/Windows/synthetic.log")); + let sid = Regex::new(r"\bS-1-\d+(?:-\d+){2,}\b").unwrap(); + let email = Regex::new(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b").unwrap(); + for file in walk_files(&task_sequence_root()) { + let contents = std::fs::read_to_string(&file).expect("fixture file is UTF-8"); + for forbidden in [ + "CONTOSO", + "Authorization:", + "Bearer ", + "client_secret", + "serverRootCause", + "appPolicyRootCause", + "nativeWindowsAccepted", + ".log.lo_", + ] { + assert!( + !contents.contains(forbidden), + "{} contains forbidden fixture material {forbidden}", + file.display() + ); + } + assert!( + !profile_path.is_match(&contents), + "{} contains an unsanitized Windows path", + file.display() + ); + assert!( + !sid.is_match(&contents) && !email.is_match(&contents), + "{} contains possible private identity material", + file.display() + ); + } +} + +#[test] +fn adversarial_contract_mutations_fail_closed() { + let incomplete_root = task_sequence_root().join("incomplete"); + let manifest = read_json(&incomplete_root.join("manifest.json")); + let mut expected = read_json(&incomplete_root.join("expected.json")); + expected["coverage"][0]["state"] = Value::String("captured".to_owned()); + let error = validate_contract("incomplete", &incomplete_root, &manifest, &expected) + .expect_err("absent manifest coverage cannot self-declare captured"); + assert!(error.contains("coverage"), "{error}"); + + let mut version_drift = manifest.clone(); + version_drift["sccmManifestVersion"] = Value::from(2); + let expected = read_json(&incomplete_root.join("expected.json")); + let error = validate_contract("incomplete", &incomplete_root, &version_drift, &expected) + .expect_err("manifest version drift must fail closed"); + assert!(error.contains("boundary metadata"), "{error}"); + + let winpe_root = task_sequence_root().join("winpe"); + let manifest = read_json(&winpe_root.join("manifest.json")); + let mut expected = read_json(&winpe_root.join("expected.json")); + expected["transactions"][0]["phase"] = Value::String("complete".to_owned()); + let error = validate_contract("winpe", &winpe_root, &manifest, &expected) + .expect_err("phase must bind to cited CCM evidence"); + assert!(error.contains("phase/state"), "{error}"); + + let unrelated_root = task_sequence_root().join("unrelated-runs"); + let manifest = read_json(&unrelated_root.join("manifest.json")); + let mut expected = read_json(&unrelated_root.join("expected.json")); + let run_b_evidence = expected["transactions"][1]["evidence"][0].clone(); + expected["transactions"][0]["evidence"] + .as_array_mut() + .expect("run A evidence is an array") + .push(run_b_evidence); + expected["transactions"][0]["key"]["advertisementId"] = Value::String("LAB20308".to_owned()); + let error = validate_contract("unrelated-runs", &unrelated_root, &manifest, &expected) + .expect_err("one exact key cannot be pooled across unrelated complete records"); + assert!(error.contains("co-occur"), "{error}"); + + let completed_root = task_sequence_root().join("completed"); + let manifest = read_json(&completed_root.join("manifest.json")); + let mut group_drift = manifest.clone(); + group_drift["artifacts"][0]["designOnlyCatalog"]["groupMemberships"] = + serde_json::json!(["client-task-sequence-other"]); + let expected = read_json(&completed_root.join("expected.json")); + let error = validate_contract("completed", &completed_root, &group_drift, &expected) + .expect_err("design-only group drift must fail closed"); + assert!(error.contains("group membership"), "{error}"); + + let mut expected = read_json(&completed_root.join("expected.json")); + expected["transactions"][0]["key"]["executionId"] = + Value::String("ffffffff-ffff-ffff-ffff-ffffffffffff".to_owned()); + let error = validate_contract("completed", &completed_root, &manifest, &expected) + .expect_err("execution key must bind to cited evidence"); + assert!(error.contains("executionId"), "{error}"); + + let mut expected = read_json(&completed_root.join("expected.json")); + expected["transactions"][0]["timestampProvenance"]["normalizedUtc"] = + Value::String("2026-07-30T23:59:59Z".to_owned()); + let error = validate_contract("completed", &completed_root, &manifest, &expected) + .expect_err("timestamp must bind to one cited CCM record"); + assert!(error.contains("timestamp"), "{error}"); + + let mut duplicate_manifest = manifest.clone(); + let duplicate_artifact = duplicate_manifest["artifacts"][0].clone(); + duplicate_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .push(duplicate_artifact); + duplicate_manifest["artifacts"][1]["artifactId"] = + Value::String("task-sequence-completed-alias".to_owned()); + let expected = read_json(&completed_root.join("expected.json")); + let error = validate_contract("completed", &completed_root, &duplicate_manifest, &expected) + .expect_err("two artifact IDs cannot alias one evidence path"); + assert!(error.contains("duplicate evidence path"), "{error}"); + + let unkeyed_root = task_sequence_root().join("complete-looking-unkeyed"); + let manifest = read_json(&unkeyed_root.join("manifest.json")); + let mut expected = read_json(&unkeyed_root.join("expected.json")); + expected["sourceLocalObservations"][0]["confidenceCeiling"] = Value::String("high".to_owned()); + let error = validate_contract( + "complete-looking-unkeyed", + &unkeyed_root, + &manifest, + &expected, + ) + .expect_err("unkeyed complete-looking evidence stays Low"); + assert!(error.contains("Low"), "{error}"); + + let failure_root = task_sequence_root().join("terminal-preflight"); + let manifest = read_json(&failure_root.join("manifest.json")); + let mut expected = read_json(&failure_root.join("expected.json")); + expected["transactions"][0]["terminalEvidence"] = Value::Null; + let error = validate_contract("terminal-preflight", &failure_root, &manifest, &expected) + .expect_err("confirmed failure requires cited terminal evidence"); + assert!(error.contains("terminal"), "{error}"); +} + +#[test] +fn last_successful_phase_requires_the_admissible_observed_phase() { + for scenario in [ + "terminal-preflight", + "client-installed", + "reboot-continuation", + ] { + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let mut expected = read_json(&scenario_root.join("expected.json")); + expected["transactions"][0]["lastSuccessfulPhase"] = Value::String("complete".to_owned()); + + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("an uncited later phase cannot become the last successful phase"); + assert!(error.contains("lastSuccessfulPhase"), "{scenario}: {error}"); + } +} + +#[test] +fn exact_key_kind_is_bound_to_the_task_sequence_profile() { + let scenario = "completed"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let mut expected = read_json(&scenario_root.join("expected.json")); + expected["transactions"][0]["key"]["keyProfileKind"] = + Value::String("filenameTimestamp".to_owned()); + + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("an exact key cannot advertise an unrelated profile kind"); + assert!(error.contains("keyProfileKind"), "{error}"); +} + +#[test] +fn transaction_evidence_order_is_canonical_and_unique() { + let scenario = "relocated-fragments"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let mut expected = read_json(&scenario_root.join("expected.json")); + let evidence = expected["transactions"][0]["evidence"] + .as_array_mut() + .expect("transaction evidence is an array"); + let last = evidence.len() - 1; + evidence.swap(0, last); + + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("transaction evidence must retain canonical relocation order"); + assert!(error.contains("evidence order"), "{error}"); +} + +#[test] +fn source_local_observation_ids_must_be_unique() { + let scenario = "unknown-profile"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let mut expected = read_json(&scenario_root.join("expected.json")); + let duplicate = expected["sourceLocalObservations"][0].clone(); + expected["sourceLocalObservations"] + .as_array_mut() + .expect("source-local observations are an array") + .push(duplicate); + + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("sorted duplicate observation IDs must fail closed"); + assert!(error.contains("observation IDs"), "{error}"); +} + +#[test] +fn finding_ids_must_be_unique() { + let scenario = "winpe"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let mut expected = read_json(&scenario_root.join("expected.json")); + let duplicate = expected["findings"][0].clone(); + expected["findings"] + .as_array_mut() + .expect("findings are an array") + .push(duplicate); + + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("sorted duplicate finding IDs must fail closed"); + assert!(error.contains("finding IDs"), "{error}"); +} + +#[test] +fn complete_winpe_record_may_have_no_smsts_path_observation() { + let scenario = "winpe"; + let temporary = copy_scenario_to_temporary_root(scenario, "no-smsts-path-token"); + let mut manifest = read_json(&temporary.root.join("manifest.json")); + let mut expected = read_json(&temporary.root.join("expected.json")); + let relative_path = manifest["artifacts"][0]["relativePath"] + .as_str() + .expect("WinPE artifact has a relative path"); + let evidence_path = temporary.root.join(relative_path); + let original = std::fs::read_to_string(&evidence_path).expect("WinPE evidence is readable"); + let without_path = original.replace( + " _SMSTSLogPath=SYNTHETIC://winpe/Windows/temp/smstslog/smsts.log", + "", + ); + assert_ne!( + without_path, original, + "the path token mutation is effective" + ); + std::fs::write(&evidence_path, &without_path).expect("mutated evidence is writable"); + + manifest["artifacts"][0]["bytesCopied"] = Value::from(without_path.len() as u64); + manifest["artifacts"][0]["smstsLogPathEvidence"] = Value::Null; + expected["artifactProvenance"][0]["bytesCopied"] = Value::from(without_path.len() as u64); + expected["artifactProvenance"][0]["smstsLogPathEvidence"] = Value::Null; + + validate_contract(scenario, &temporary.root, &manifest, &expected) + .expect("a complete logical CCM record may lack an observed _SMSTSLogPath"); +} + +#[test] +fn equal_relocation_timestamps_are_ambiguous() { + let scenario = "relocated-fragments"; + let temporary = copy_scenario_to_temporary_root(scenario, "equal-relocation-timestamps"); + let mut manifest = read_json(&temporary.root.join("manifest.json")); + let mut expected = read_json(&temporary.root.join("expected.json")); + let artifact_id = "task-sequence-relocated-02-setup"; + let artifact_index = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .position(|artifact| artifact["artifactId"] == artifact_id) + .expect("setup relocation artifact exists"); + let relative_path = manifest["artifacts"][artifact_index]["relativePath"] + .as_str() + .expect("setup relocation artifact has a relative path") + .to_owned(); + let evidence_path = temporary.root.join(relative_path); + let original = std::fs::read_to_string(&evidence_path).expect("setup evidence is readable"); + let tied = original.replace("01:10:01.000+000", "01:10:00.000+000"); + assert_ne!(tied, original, "the equal-timestamp mutation is effective"); + std::fs::write(&evidence_path, &tied).expect("mutated setup evidence is writable"); + + manifest["artifacts"][artifact_index]["bytesCopied"] = Value::from(tied.len() as u64); + let provenance_index = expected["artifactProvenance"] + .as_array() + .expect("artifact provenance is an array") + .iter() + .position(|item| item["artifactId"] == artifact_id) + .expect("setup relocation provenance exists"); + expected["artifactProvenance"][provenance_index]["bytesCopied"] = + Value::from(tied.len() as u64); + + let error = validate_contract(scenario, &temporary.root, &manifest, &expected) + .expect_err("equal cited timestamps cannot establish relocation order"); + assert!(error.contains("ambiguous"), "{error}"); +} + +#[test] +fn timestamp_binding_preserves_millisecond_precision() { + let scenario = "winpe"; + let temporary = copy_scenario_to_temporary_root(scenario, "subsecond-timestamp"); + let mut manifest = read_json(&temporary.root.join("manifest.json")); + let mut expected = read_json(&temporary.root.join("expected.json")); + let relative_path = manifest["artifacts"][0]["relativePath"] + .as_str() + .expect("WinPE artifact has a relative path"); + let evidence_path = temporary.root.join(relative_path); + let original = std::fs::read_to_string(&evidence_path).expect("WinPE evidence is readable"); + let subsecond = original.replace("01:00:01.000+000", "01:00:01.123+000"); + assert_ne!( + subsecond, original, + "the subsecond timestamp mutation is effective" + ); + std::fs::write(&evidence_path, &subsecond).expect("mutated evidence is writable"); + + manifest["artifacts"][0]["bytesCopied"] = Value::from(subsecond.len() as u64); + expected["artifactProvenance"][0]["bytesCopied"] = Value::from(subsecond.len() as u64); + + let error = validate_contract(scenario, &temporary.root, &manifest, &expected) + .expect_err("whole-second expected output cannot match subsecond evidence"); + assert!(error.contains("timestamp"), "{error}"); +} + +#[test] +fn coherent_review_mutations_fail_closed() { + let mut accepted = Vec::new(); + + let completed_root = task_sequence_root().join("completed"); + let completed_manifest = read_json(&completed_root.join("manifest.json")); + let completed_expected = read_json(&completed_root.join("expected.json")); + + let mut manifest = completed_manifest.clone(); + let mut expected = completed_expected.clone(); + manifest["artifacts"][0]["pathClass"] = Value::String("setup".to_owned()); + expected["coverage"][0]["pathClasses"] = serde_json::json!(["setup"]); + expected["artifactProvenance"][0]["pathClass"] = Value::String("setup".to_owned()); + expected["transactions"][0]["pathSequence"][0]["pathClass"] = Value::String("setup".to_owned()); + if validate_contract("completed", &completed_root, &manifest, &expected).is_ok() { + accepted.push("pathClass drift"); + } + + let mut manifest = completed_manifest.clone(); + let mut expected = completed_expected.clone(); + let drifted_path = Value::String("SYNTHETIC://client/drift/smsts.log".to_owned()); + manifest["artifacts"][0]["sanitizedSourcePath"] = drifted_path.clone(); + manifest["artifacts"][0]["smstsLogPathEvidence"] = drifted_path.clone(); + expected["artifactProvenance"][0]["sanitizedSourcePath"] = drifted_path.clone(); + expected["artifactProvenance"][0]["smstsLogPathEvidence"] = drifted_path; + if validate_contract("completed", &completed_root, &manifest, &expected).is_ok() { + accepted.push("_SMSTSLogPath drift"); + } + + let mut manifest = completed_manifest.clone(); + manifest["artifacts"][0]["sourceVersion"] = Value::String("5.00.UNKNOWN.0000".to_owned()); + if validate_contract("completed", &completed_root, &manifest, &completed_expected).is_ok() { + accepted.push("sourceVersion drift"); + } + + let mut expected = completed_expected.clone(); + expected["extractionProfile"]["id"] = + Value::String("task-sequence-client-9.99.drift-v1".to_owned()); + if validate_contract("completed", &completed_root, &completed_manifest, &expected).is_ok() { + accepted.push("extraction profile drift"); + } + + let mut manifest = completed_manifest.clone(); + manifest["artifacts"][0]["capturedUtc"] = Value::String("not-a-timestamp".to_owned()); + if validate_contract("completed", &completed_root, &manifest, &completed_expected).is_ok() { + accepted.push("invalid capturedUtc"); + } + + let rotation_root = task_sequence_root().join("rotation-boundary"); + let mut manifest = read_json(&rotation_root.join("manifest.json")); + let mut expected = read_json(&rotation_root.join("expected.json")); + let lo_index = manifest["artifacts"] + .as_array() + .expect("rotation artifacts are an array") + .iter() + .position(|artifact| artifact["rotation"]["kind"] == "lo") + .expect("rotation corpus has smsts.lo_"); + manifest["artifacts"][lo_index]["rotation"]["fragmentComplete"] = Value::Bool(true); + let lo_id = manifest["artifacts"][lo_index]["artifactId"].clone(); + let provenance_index = expected["artifactProvenance"] + .as_array() + .expect("rotation provenance is an array") + .iter() + .position(|item| item["artifactId"] == lo_id) + .expect("rotation provenance contains smsts.lo_"); + expected["artifactProvenance"][provenance_index]["fragmentComplete"] = Value::Bool(true); + expected["coverage"][0]["state"] = Value::String("captured".to_owned()); + if validate_contract("rotation-boundary", &rotation_root, &manifest, &expected).is_ok() { + accepted.push("partial smsts.lo_ promoted complete"); + } + + let relocated_root = task_sequence_root().join("relocated-fragments"); + let mut manifest = read_json(&relocated_root.join("manifest.json")); + let mut expected = read_json(&relocated_root.join("expected.json")); + manifest["artifacts"][1]["relocationOrdinal"] = Value::from(2); + manifest["artifacts"][2]["relocationOrdinal"] = Value::from(1); + expected["artifactProvenance"][1]["relocationOrdinal"] = Value::from(2); + expected["artifactProvenance"][2]["relocationOrdinal"] = Value::from(1); + expected["transactions"][0]["pathSequence"][1]["relocationOrdinal"] = Value::from(2); + expected["transactions"][0]["pathSequence"][2]["relocationOrdinal"] = Value::from(1); + expected["transactions"][0]["pathSequence"] + .as_array_mut() + .expect("path sequence is an array") + .swap(1, 2); + if validate_contract("relocated-fragments", &relocated_root, &manifest, &expected).is_ok() { + accepted.push("relocation order drift"); + } + + let unkeyed_root = task_sequence_root().join("complete-looking-unkeyed"); + let unkeyed_manifest = read_json(&unkeyed_root.join("manifest.json")); + let mut expected = read_json(&unkeyed_root.join("expected.json")); + expected["findings"][0]["classification"] = Value::String("success".to_owned()); + if validate_contract( + "complete-looking-unkeyed", + &unkeyed_root, + &unkeyed_manifest, + &expected, + ) + .is_ok() + { + accepted.push("unkeyed evidence promoted success"); + } + + let mut expected = completed_expected.clone(); + expected["transactions"][0]["terminalEvidence"] = Value::Null; + if validate_contract("completed", &completed_root, &completed_manifest, &expected).is_ok() { + accepted.push("success without terminal citation"); + } + + let nonterminal_root = task_sequence_root().join("client-installed"); + let nonterminal_manifest = read_json(&nonterminal_root.join("manifest.json")); + let mut expected = read_json(&nonterminal_root.join("expected.json")); + expected["findings"][0]["classification"] = Value::String("confirmedFailure".to_owned()); + if validate_contract( + "client-installed", + &nonterminal_root, + &nonterminal_manifest, + &expected, + ) + .is_ok() + { + accepted.push("nonterminal finding promoted confirmedFailure"); + } + + let invalid_offset_root = task_sequence_root().join("invalid-offset"); + let invalid_offset_manifest = read_json(&invalid_offset_root.join("manifest.json")); + let mut expected = read_json(&invalid_offset_root.join("expected.json")); + expected["transactions"][0]["confidence"] = Value::String("high".to_owned()); + expected["transactions"][0]["confidenceCeiling"] = Value::String("high".to_owned()); + if validate_contract( + "invalid-offset", + &invalid_offset_root, + &invalid_offset_manifest, + &expected, + ) + .is_ok() + { + accepted.push("invalid offset promoted High"); + } + + let unknown_root = task_sequence_root().join("unknown-profile"); + let unknown_manifest = read_json(&unknown_root.join("manifest.json")); + let mut expected = read_json(&unknown_root.join("expected.json")); + let winpe_expected = read_json(&task_sequence_root().join("winpe").join("expected.json")); + let mut transaction = winpe_expected["transactions"][0].clone(); + transaction["transactionId"] = Value::String("task-sequence-016".to_owned()); + transaction["key"]["executionId"] = + Value::String("72400000-0000-0000-0000-000000000016".to_owned()); + transaction["key"]["advertisementId"] = Value::String("LAB20316".to_owned()); + transaction["evidence"][0]["artifactId"] = + Value::String("task-sequence-unknown-profile-smsts-current".to_owned()); + transaction["pathSequence"][0]["artifactId"] = + Value::String("task-sequence-unknown-profile-smsts-current".to_owned()); + transaction["pathSequence"][0]["pathClass"] = Value::String("unknown".to_owned()); + transaction["orderingEvidence"]["artifactId"] = + Value::String("task-sequence-unknown-profile-smsts-current".to_owned()); + transaction["timestampProvenance"]["normalizedUtc"] = + Value::String("2026-07-30T01:46:00Z".to_owned()); + transaction["nextArtifact"] = Value::Null; + transaction["confidence"] = Value::String("high".to_owned()); + transaction["confidenceCeiling"] = Value::String("high".to_owned()); + expected["extractionProfile"] = serde_json::json!({ + "id": "task-sequence-client-5.00.test-v1", + "status": "matched" + }); + expected["transactions"] = serde_json::json!([transaction]); + expected["sourceLocalObservations"] = serde_json::json!([]); + if validate_contract( + "unknown-profile", + &unknown_root, + &unknown_manifest, + &expected, + ) + .is_ok() + { + accepted.push("unknown source promoted exact High"); + } + + let incomplete_root = task_sequence_root().join("incomplete"); + let incomplete_manifest = read_json(&incomplete_root.join("manifest.json")); + let mut expected = read_json(&incomplete_root.join("expected.json")); + expected["findings"][0]["boundedNextArtifact"] = serde_json::json!({ + "logicalArtifactId": "all-device-artifacts", + "pathClass": "everywhere", + "reason": "Collect everything." + }); + if validate_contract( + "incomplete", + &incomplete_root, + &incomplete_manifest, + &expected, + ) + .is_ok() + { + accepted.push("unbounded finding request"); + } + + let mut expected = read_json(&incomplete_root.join("expected.json")); + expected["findings"][0]["classification"] = Value::String("success".to_owned()); + expected["findings"][0]["noTaskSequenceRan"] = Value::Bool(true); + if validate_contract( + "incomplete", + &incomplete_root, + &incomplete_manifest, + &expected, + ) + .is_ok() + { + accepted.push("absent coverage promoted success/no-run"); + } + + assert!( + accepted.is_empty(), + "validate_contract accepted {} coherent review mutations: {}", + accepted.len(), + accepted.join(", ") + ); +} + +#[test] +fn rotation_path_provenance_cannot_be_borrowed_from_a_shared_fingerprint() { + let scenario = "rotation-boundary"; + let source_root = task_sequence_root().join(scenario); + let source_manifest = read_json(&source_root.join("manifest.json")); + let source_expected = read_json(&source_root.join("expected.json")); + let original_path = "SYNTHETIC://client/CCM/Logs/smsts.log"; + let drifted_path = "SYNTHETIC://client/CCM/Logs/drift/smsts.log"; + let lo_id = "task-sequence-rotation-boundary-lo"; + let mut accepted = Vec::new(); + + let changed = copy_scenario_to_temporary_root(scenario, "changed-path-token"); + let mut manifest = source_manifest.clone(); + let mut expected = source_expected.clone(); + let lo_index = manifest["artifacts"] + .as_array() + .expect("rotation artifacts are an array") + .iter() + .position(|artifact| artifact["artifactId"] == lo_id) + .expect("rotation corpus has smsts.lo_"); + let lo_relative_path = manifest["artifacts"][lo_index]["relativePath"] + .as_str() + .expect("smsts.lo_ has a relative path"); + let lo_path = changed.root.join(lo_relative_path); + let original_lo = std::fs::read_to_string(&lo_path).expect("smsts.lo_ is UTF-8"); + let changed_lo = original_lo.replace(original_path, drifted_path); + assert_ne!(original_lo, changed_lo, "the path token mutation applies"); + std::fs::write(&lo_path, &changed_lo).expect("mutated smsts.lo_ is written"); + let changed_bytes = changed_lo.len() as u64; + for artifact in manifest["artifacts"] + .as_array_mut() + .expect("rotation artifacts are an array") + { + artifact["sanitizedSourcePath"] = Value::String(drifted_path.to_owned()); + artifact["smstsLogPathEvidence"] = Value::String(drifted_path.to_owned()); + if artifact["artifactId"] == lo_id { + artifact["bytesCopied"] = Value::from(changed_bytes); + } + } + for provenance in expected["artifactProvenance"] + .as_array_mut() + .expect("rotation provenance is an array") + { + provenance["sanitizedSourcePath"] = Value::String(drifted_path.to_owned()); + provenance["smstsLogPathEvidence"] = Value::String(drifted_path.to_owned()); + if provenance["artifactId"] == lo_id { + provenance["bytesCopied"] = Value::from(changed_bytes); + } + } + expected["logicalReconstructions"][0]["sanitizedSourcePath"] = + Value::String(drifted_path.to_owned()); + if validate_contract(scenario, &changed.root, &manifest, &expected).is_ok() { + accepted.push("current fragment borrowed changed lo path"); + } + + let donor = copy_scenario_to_temporary_root(scenario, "same-fingerprint-donor"); + let mut manifest = source_manifest.clone(); + let mut expected = source_expected.clone(); + let lo_index = manifest["artifacts"] + .as_array() + .expect("rotation artifacts are an array") + .iter() + .position(|artifact| artifact["artifactId"] == lo_id) + .expect("rotation corpus has smsts.lo_"); + let lo_relative_path = manifest["artifacts"][lo_index]["relativePath"] + .as_str() + .expect("smsts.lo_ has a relative path") + .to_owned(); + let lo_path = donor.root.join(&lo_relative_path); + let original_lo = std::fs::read_to_string(&lo_path).expect("smsts.lo_ is UTF-8"); + let removed_lo = original_lo.replace("_SMSTSLogPath=", "_REMOVEDLogPath="); + assert_ne!(original_lo, removed_lo, "the path token removal applies"); + std::fs::write(&lo_path, &removed_lo).expect("mutated smsts.lo_ is written"); + let removed_bytes = removed_lo.len() as u64; + manifest["artifacts"][lo_index]["bytesCopied"] = Value::from(removed_bytes); + let lo_provenance_index = expected["artifactProvenance"] + .as_array() + .expect("rotation provenance is an array") + .iter() + .position(|item| item["artifactId"] == lo_id) + .expect("rotation provenance contains smsts.lo_"); + expected["artifactProvenance"][lo_provenance_index]["bytesCopied"] = Value::from(removed_bytes); + + let donor_id = "task-sequence-rotation-boundary-donor"; + let donor_relative_path = "evidence/client-task-sequence-smsts/client/donor/smsts.lo_"; + let donor_path = donor.root.join(donor_relative_path); + std::fs::create_dir_all( + donor_path + .parent() + .expect("donor evidence has a parent directory"), + ) + .expect("donor directory is created"); + std::fs::write(&donor_path, &original_lo).expect("donor evidence is written"); + let mut donor_artifact = manifest["artifacts"][lo_index].clone(); + donor_artifact["artifactId"] = Value::String(donor_id.to_owned()); + donor_artifact["relativePath"] = Value::String(donor_relative_path.to_owned()); + donor_artifact["bytesCopied"] = Value::from(original_lo.len() as u64); + manifest["artifacts"] + .as_array_mut() + .expect("rotation artifacts are an array") + .push(donor_artifact); + + let mut donor_provenance = expected["artifactProvenance"][lo_provenance_index].clone(); + donor_provenance["artifactId"] = Value::String(donor_id.to_owned()); + donor_provenance["bytesCopied"] = Value::from(original_lo.len() as u64); + expected["artifactProvenance"] + .as_array_mut() + .expect("rotation provenance is an array") + .insert(1, donor_provenance); + expected["coverage"][0]["artifactIds"] + .as_array_mut() + .expect("partial artifact IDs are an array") + .insert(1, Value::String(donor_id.to_owned())); + if validate_contract(scenario, &donor.root, &manifest, &expected).is_ok() { + accepted.push("same-fingerprint donor supplied another fragment path"); + } + + assert!( + accepted.is_empty(), + "validate_contract accepted {} shared-fingerprint provenance mutations: {}", + accepted.len(), + accepted.join(", ") + ); +} + +#[test] +fn exact_key_admission_requires_complete_value_tokens() { + let scenario = "completed"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let mut expected = read_json(&scenario_root.join("expected.json")); + expected["transactions"][0]["key"]["executionId"] = + Value::String("72400000-0000-0000-0000-00000000000".to_owned()); + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("a strict prefix of the recorded execution token is not the exact key"); + assert!(error.contains("co-occur"), "{error}"); + + let temporary = copy_scenario_to_temporary_root(scenario, "suffixed-execution-token"); + let mut manifest = read_json(&temporary.root.join("manifest.json")); + let mut expected = read_json(&temporary.root.join("expected.json")); + let relative_path = manifest["artifacts"][0]["relativePath"] + .as_str() + .expect("completed artifact has a relative path"); + let evidence_path = temporary.root.join(relative_path); + let original = std::fs::read_to_string(&evidence_path).expect("completed evidence is readable"); + let suffixed = original.replace( + "executionId=72400000-0000-0000-0000-000000000005 ", + "executionId=72400000-0000-0000-0000-000000000005X ", + ); + assert_ne!( + suffixed, original, + "the suffixed execution token mutation is effective" + ); + std::fs::write(&evidence_path, &suffixed).expect("mutated evidence is writable"); + manifest["artifacts"][0]["bytesCopied"] = Value::from(suffixed.len() as u64); + expected["artifactProvenance"][0]["bytesCopied"] = Value::from(suffixed.len() as u64); + + let error = validate_contract(scenario, &temporary.root, &manifest, &expected) + .expect_err("a suffixed recorded token cannot satisfy the declared exact key"); + assert!(error.contains("co-occur"), "{error}"); +} + +#[test] +fn correlation_boundary_declaration_is_bound_to_enforcement() { + let scenario = "completed"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + + let mut expected = read_json(&scenario_root.join("expected.json")); + expected["correlationBoundary"]["scope"] = Value::String("crossSideHighConfidence".to_owned()); + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("a cross-side scope exceeds the enforced client-side boundary"); + assert!(error.contains("correlation scope"), "{error}"); + + let mut expected = read_json(&scenario_root.join("expected.json")); + expected["correlationBoundary"]["joinFields"] = serde_json::json!(["timestamp"]); + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("declared join fields must be the enforced exact key fields"); + assert!(error.contains("exact key fields"), "{error}"); + + let mut expected = read_json(&scenario_root.join("expected.json")); + expected["correlationBoundary"]["forbiddenJoinFields"] = + serde_json::json!(["filename", "path", "timestamp", "displayName"]); + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("a forbidden list omitting component is not the enforced list"); + assert!(error.contains("forbidden join fields"), "{error}"); + + let mut expected = read_json(&scenario_root.join("expected.json")); + expected["correlationBoundary"]["forbiddenJoinFields"] = serde_json::json!([]); + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("an empty forbidden list is not the enforced list"); + assert!(error.contains("forbidden join fields"), "{error}"); +} + +#[test] +fn noncapture_artifacts_cannot_carry_fragment_rotation_metadata() { + let scenario = "incomplete"; + let scenario_root = task_sequence_root().join(scenario); + let mut manifest = read_json(&scenario_root.join("manifest.json")); + let expected = read_json(&scenario_root.join("expected.json")); + manifest["artifacts"][0]["rotation"]["fragmentComplete"] = Value::Bool(false); + + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("an absent artifact cannot claim physical fragment completeness"); + assert!(error.contains("fragment"), "{error}"); +} + +#[test] +fn finding_evidence_cannot_mix_unrelated_exact_runs() { + let scenario = "unrelated-runs"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let mut expected = read_json(&scenario_root.join("expected.json")); + let run_b_evidence = expected["transactions"][1]["evidence"][0].clone(); + expected["findings"][0]["evidence"] + .as_array_mut() + .expect("run A finding evidence is an array") + .push(run_b_evidence); + + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("one finding cannot cite evidence from a different exact run"); + assert!(error.contains("bound"), "{error}"); +} + +#[test] +fn exact_key_admission_ignores_bracket_bounded_prefixes() { + let scenario = "completed"; + for (mutation, original_token, mutated_token) in [ + ( + "bracket-suffixed-run-context", + "runContext=osd ", + "runContext=osd]stray ", + ), + ( + "angle-suffixed-advertisement", + "advertisementId=LAB20305 ", + "advertisementId=LAB20305 ", + ), + ] { + let temporary = copy_scenario_to_temporary_root(scenario, mutation); + let mut manifest = read_json(&temporary.root.join("manifest.json")); + let mut expected = read_json(&temporary.root.join("expected.json")); + let relative_path = manifest["artifacts"][0]["relativePath"] + .as_str() + .expect("completed artifact has a relative path"); + let evidence_path = temporary.root.join(relative_path); + let original = + std::fs::read_to_string(&evidence_path).expect("completed evidence is readable"); + let mutated = original.replace(original_token, mutated_token); + assert_ne!(mutated, original, "{mutation}: the mutation is effective"); + std::fs::write(&evidence_path, &mutated).expect("mutated evidence is writable"); + manifest["artifacts"][0]["bytesCopied"] = Value::from(mutated.len() as u64); + expected["artifactProvenance"][0]["bytesCopied"] = Value::from(mutated.len() as u64); + + let error = validate_contract(scenario, &temporary.root, &manifest, &expected) + .expect_err("a bracket-bounded prefix of the recorded value is not the exact key"); + assert!(error.contains("co-occur"), "{mutation}: {error}"); + } +} + +fn aliasing_observation(observation_id: &str, evidence: &Value) -> Value { + serde_json::json!({ + "observationId": observation_id, + "artifactId": evidence["artifactId"].clone(), + "keyConfidence": "candidate", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "evidence": evidence.clone(), + "reason": "Synthetic alias of already keyed evidence." + }) +} + +#[test] +fn padded_evidence_references_cannot_alias_keyed_records() { + let scenario = "unrelated-runs"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let source = read_json(&scenario_root.join("expected.json")); + let run_a_evidence = source["transactions"][0]["evidence"][0].clone(); + + let mut expected = source.clone(); + expected["sourceLocalObservations"] = serde_json::json!([aliasing_observation( + "unrelated-runs-alias-a", + &run_a_evidence + )]); + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("control: an unpadded alias of keyed evidence is rejected"); + assert!(error.contains("source-local"), "{error}"); + + let mut padded_evidence = run_a_evidence.clone(); + padded_evidence["reviewerNote"] = Value::String("evidence_text ignores this key".to_owned()); + let mut expected = source; + expected["sourceLocalObservations"] = serde_json::json!([aliasing_observation( + "unrelated-runs-alias-a", + &padded_evidence + )]); + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("a padded alias resolves to the same record and must also be rejected"); + assert!(error.contains("unmodeled"), "{error}"); +} + +#[test] +fn padded_observations_cannot_launder_cross_run_findings() { + let scenario = "unrelated-runs"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let mut expected = read_json(&scenario_root.join("expected.json")); + let mut padded_a = expected["transactions"][0]["evidence"][0].clone(); + let mut padded_b = expected["transactions"][1]["evidence"][0].clone(); + padded_a["reviewerNote"] = Value::String("padding".to_owned()); + padded_b["reviewerNote"] = Value::String("padding".to_owned()); + expected["sourceLocalObservations"] = serde_json::json!([ + aliasing_observation("unrelated-runs-alias-a", &padded_a), + aliasing_observation("unrelated-runs-alias-b", &padded_b) + ]); + // The finding cites the padded aliases, so every reference resolves to a + // keyed physical record while matching only the laundering observations. + expected["findings"][0]["evidence"] = serde_json::json!([padded_a, padded_b]); + + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("padded observations cannot launder a finding across two exact runs"); + assert!(error.contains("unmodeled"), "{error}"); +} + +#[test] +fn undelimitable_record_bodies_fail_closed() { + let scenario = "completed"; + let key_fields = concat!( + "executionId=72400000-0000-0000-0000-000000000005 ", + "taskSequencePackageId=LAB00324 advertisementId=LAB20305 runContext=osd" + ); + + for (mutation, leading_space, relocate_key_fields, must_fail_at_head) in [ + ("leading-space", true, false, false), + ("relocated-key-fields-indented", true, true, false), + ("relocated-key-fields-flush", false, true, true), + ] { + let temporary = copy_scenario_to_temporary_root(scenario, mutation); + let mut manifest = read_json(&temporary.root.join("manifest.json")); + let mut expected = read_json(&temporary.root.join("expected.json")); + let relative_path = manifest["artifacts"][0]["relativePath"] + .as_str() + .expect("completed artifact has a relative path"); + let evidence_path = temporary.root.join(relative_path); + let original = + std::fs::read_to_string(&evidence_path).expect("completed evidence is readable"); + + let mut mutated = original.clone(); + if relocate_key_fields { + mutated = mutated.replace(&format!("{key_fields} "), ""); + mutated = mutated.replace("context=\"\"", &format!("context=\"{key_fields}\"")); + assert!( + mutated.contains(&format!("context=\"{key_fields}\"")), + "{mutation}: the key fields moved into the record trailer" + ); + } + if leading_space { + mutated = format!(" {mutated}"); + } + assert_ne!(mutated, original, "{mutation}: the mutation is effective"); + std::fs::write(&evidence_path, &mutated).expect("mutated evidence is writable"); + manifest["artifacts"][0]["bytesCopied"] = Value::from(mutated.len() as u64); + expected["artifactProvenance"][0]["bytesCopied"] = Value::from(mutated.len() as u64); + + let result = validate_contract(scenario, &temporary.root, &manifest, &expected); + if must_fail_at_head { + result.expect_err("control: a flush record never exposes its trailer to key admission"); + } else { + let error = result.expect_err( + "a record body that cannot be delimited by the CCM framing must fail closed", + ); + assert!(error.contains("framing"), "{mutation}: {error}"); + } + } +} + +#[test] +fn rotated_fragment_capture_path_names_the_rotated_file() { + let scenario_root = task_sequence_root().join("rotation-boundary"); + let manifest = read_json(&scenario_root.join("manifest.json")); + let expected = read_json(&scenario_root.join("expected.json")); + let lo_id = "task-sequence-rotation-boundary-lo"; + let lo = manifest["artifacts"] + .as_array() + .expect("rotation artifacts are an array") + .iter() + .find(|artifact| artifact["artifactId"] == lo_id) + .expect("rotation corpus has smsts.lo_"); + + assert_eq!( + lo["sanitizedSourcePath"], "SYNTHETIC://client/CCM/Logs/smsts.lo_", + "the rotated artifact's capture provenance must name the rotated physical file" + ); + assert_eq!( + lo["smstsLogPathEvidence"], "SYNTHETIC://client/CCM/Logs/smsts.log", + "the in-record observation stays the active log path it physically records" + ); + let lo_provenance = expected["artifactProvenance"] + .as_array() + .expect("rotation provenance is an array") + .iter() + .find(|item| item["artifactId"] == lo_id) + .expect("rotation provenance contains smsts.lo_"); + assert_eq!( + lo_provenance["sanitizedSourcePath"], lo["sanitizedSourcePath"], + "expected output mirrors the corrected capture provenance" + ); +} + +#[test] +fn capture_source_path_basename_is_bound_to_rotation() { + let rotation_root = task_sequence_root().join("rotation-boundary"); + let rotation_manifest = read_json(&rotation_root.join("manifest.json")); + let mut manifest = rotation_manifest.clone(); + let mut expected = read_json(&rotation_root.join("expected.json")); + let lo_id = "task-sequence-rotation-boundary-lo"; + let active_log_path = Value::String("SYNTHETIC://client/CCM/Logs/smsts.log".to_owned()); + let lo_index = manifest["artifacts"] + .as_array() + .expect("rotation artifacts are an array") + .iter() + .position(|artifact| artifact["artifactId"] == lo_id) + .expect("rotation corpus has smsts.lo_"); + manifest["artifacts"][lo_index]["sanitizedSourcePath"] = active_log_path.clone(); + let lo_provenance_index = expected["artifactProvenance"] + .as_array() + .expect("rotation provenance is an array") + .iter() + .position(|item| item["artifactId"] == lo_id) + .expect("rotation provenance contains smsts.lo_"); + expected["artifactProvenance"][lo_provenance_index]["sanitizedSourcePath"] = active_log_path; + + let error = validate_contract("rotation-boundary", &rotation_root, &manifest, &expected) + .expect_err("a rotated artifact cannot claim the active log as its capture source"); + assert!(error.contains("capture source path"), "{error}"); + + let completed_root = task_sequence_root().join("completed"); + let mut manifest = read_json(&completed_root.join("manifest.json")); + let mut expected = read_json(&completed_root.join("expected.json")); + let rotated_path = Value::String("SYNTHETIC://client/CCM/Logs/smsts.lo_".to_owned()); + manifest["artifacts"][0]["sanitizedSourcePath"] = rotated_path.clone(); + expected["artifactProvenance"][0]["sanitizedSourcePath"] = rotated_path; + + let error = validate_contract("completed", &completed_root, &manifest, &expected) + .expect_err("a current artifact cannot claim a rotated capture source"); + assert!(error.contains("capture source path"), "{error}"); +} + +#[test] +fn keyed_evidence_cannot_be_laundered_through_source_local_observations() { + let scenario = "unrelated-runs"; + let scenario_root = task_sequence_root().join(scenario); + let manifest = read_json(&scenario_root.join("manifest.json")); + let mut expected = read_json(&scenario_root.join("expected.json")); + let run_a_evidence = expected["transactions"][0]["evidence"][0].clone(); + let run_b_evidence = expected["transactions"][1]["evidence"][0].clone(); + expected["sourceLocalObservations"] = serde_json::json!([ + { + "observationId": "unrelated-runs-alias-a", + "artifactId": run_a_evidence["artifactId"].clone(), + "keyConfidence": "candidate", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "evidence": run_a_evidence.clone(), + "reason": "Synthetic alias of already keyed run A evidence." + }, + { + "observationId": "unrelated-runs-alias-b", + "artifactId": run_b_evidence["artifactId"].clone(), + "keyConfidence": "candidate", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "evidence": run_b_evidence.clone(), + "reason": "Synthetic alias of already keyed run B evidence." + } + ]); + expected["findings"][0]["evidence"] + .as_array_mut() + .expect("run A finding evidence is an array") + .push(run_b_evidence); + + let error = validate_contract(scenario, &scenario_root, &manifest, &expected) + .expect_err("keyed transaction evidence cannot be laundered into source-local citations"); + assert!(error.contains("source-local"), "{error}"); +} + +#[test] +fn phase_state_and_terminal_bind_to_body_tokens_only() { + // The completed scenario declares the strongest claim in the corpus: + // success, high confidence, phase complete, state succeeded, terminal. + let scenario = "completed"; + let mut accepted = Vec::new(); + + for (mutation, body_removal, trailer_claim, body_rewrite) in [ + ( + "phase-and-state-relocated-to-trailer", + Some("phase=complete state=succeeded "), + Some("phase=complete state=succeeded"), + None, + ), + ( + "terminal-relocated-to-trailer", + Some("terminal=true "), + Some("terminal=true"), + None, + ), + ( + "phase-token-suffixed", + None, + None, + Some(("phase=complete", "phase=completeX")), + ), + ( + "state-token-suffixed", + None, + None, + Some(("state=succeeded", "state=succeededX")), + ), + ( + "terminal-token-suffixed", + None, + None, + Some(("terminal=true", "terminal=trueX")), + ), + ( + "terminal-state-token-extended", + None, + None, + Some(("state=succeeded", "state=succeededLater")), + ), + ] { + let temporary = copy_scenario_to_temporary_root(scenario, mutation); + let mut manifest = read_json(&temporary.root.join("manifest.json")); + let mut expected = read_json(&temporary.root.join("expected.json")); + let relative_path = manifest["artifacts"][0]["relativePath"] + .as_str() + .expect("completed artifact has a relative path"); + let evidence_path = temporary.root.join(relative_path); + let original = + std::fs::read_to_string(&evidence_path).expect("completed evidence is readable"); + + let mut mutated = original.clone(); + if let Some(body_removal) = body_removal { + mutated = mutated.replace(body_removal, ""); + } + if let Some(trailer_claim) = trailer_claim { + mutated = mutated.replace("context=\"\"", &format!("context=\"{trailer_claim}\"")); + assert!( + mutated.contains(&format!("context=\"{trailer_claim}\"")), + "{mutation}: the claim moved into the record trailer" + ); + } + if let Some((from, to)) = body_rewrite { + mutated = mutated.replace(from, to); + } + assert_ne!(mutated, original, "{mutation}: the mutation is effective"); + std::fs::write(&evidence_path, &mutated).expect("mutated evidence is writable"); + manifest["artifacts"][0]["bytesCopied"] = Value::from(mutated.len() as u64); + expected["artifactProvenance"][0]["bytesCopied"] = Value::from(mutated.len() as u64); + + if validate_contract(scenario, &temporary.root, &manifest, &expected).is_ok() { + accepted.push(mutation); + } + } + + assert!( + accepted.is_empty(), + "validate_contract accepted {} trailer or partial-token outcome claims: {}", + accepted.len(), + accepted.join(", ") + ); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs new file mode 100644 index 000000000..4a79d0f35 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs @@ -0,0 +1,989 @@ +use cmtraceopen_parser::sccm::client::{ + admit_client_evidence, analyze_client_updates, assess_client_intake, SccmClientCapturedPayload, + SccmClientIntakeArtifact, SccmClientIntakeBundle, SccmClientIntakeCaptureGap, + SccmClientUpdatePhase, SccmClientUpdateState, +}; +use cmtraceopen_parser::sccm::{ + classify_artifact_name, SccmArtifact, SccmCoverageState, SccmRole, SccmRotation, +}; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{fs, path::PathBuf}; + +const UPDATE_ID: &str = "32300000-0000-0000-0000-000000000003"; +const CI_ID: &str = "323003"; + +fn sha256(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[derive(Clone)] +struct Record<'a> { + id: &'a str, + basename: &'a str, + group: &'a str, + component: &'a str, + time: &'a str, + message: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CorpusManifest { + artifacts: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CorpusArtifact { + design_only_catalog: CorpusCatalog, + capture_state: String, + encoding: Option, + original_basename: String, + rotation: CorpusRotation, + source_version: Option, + captured_utc: Option, + relative_path: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CorpusCatalog { + entry_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CorpusRotation { + kind: String, + fragment_complete: Option, +} + +fn admitted( + records: &[Record<'_>], +) -> cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence { + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + for record in records { + let rotation = if record.basename.ends_with(".lo_") { + SccmRotation::LoUnderscore + } else { + SccmRotation::Current + }; + let rotation_segment = if matches!(rotation, SccmRotation::LoUnderscore) { + "lo" + } else { + "current" + }; + let artifact_id = format!("fixture-{}", record.id); + let path_fingerprint = format!("synthetic-{}", record.id); + let time = format!("{}+000", record.time); + let bytes = format!( + "\n", + record.message, time, record.component + ) + .into_bytes(); + artifacts.push(SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: record.basename.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T23:59:59Z".to_owned()), + rotation, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some(path_fingerprint), + rotation_lineage: None, + relative_path: Some(format!( + "evidence/{}/{rotation_segment}/{}", + record.group, record.basename + )), + fragment_complete: Some(true), + declared_byte_length: Some(bytes.len() as u64), + content_sha256: Some(sha256(&bytes)), + }); + payloads.push( + SccmClientCapturedPayload::new(artifact_id.clone(), bytes) + .unwrap_or_else(|error| panic!("{artifact_id}: bounded update payload: {error}")), + ); + } + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).expect("canonical update intake"); + admit_client_evidence(&bundle, &assessment, &payloads).expect("sealed update evidence") +} + +fn admitted_scan(message: &str) -> cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence { + admitted(&[Record { + id: "update-failure", + basename: "ScanAgent.log", + group: "client-updates", + component: "ScanAgent", + time: "04:00:01.000", + message: message.to_owned(), + }]) +} + +fn keyed(update_id: &str, ci_id: &str, disposition: &str) -> String { + format!( + "{disposition} UpdateId={{{update_id}}} CIId={ci_id} \ + ContentId=CONTENT-{ci_id} UpdateJobId=JOB-{ci_id} \ + ClientHandle=safe:client:{ci_id} SiteCode=LAB SupHostHandle=safe:sup:lab" + ) +} + +fn corpus_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/updates") +} + +fn corpus_admitted( + scenario: &str, +) -> Result { + let scenario_dir = corpus_root().join(scenario); + let manifest: CorpusManifest = serde_json::from_slice( + &fs::read(scenario_dir.join("manifest.json")).expect("corpus manifest"), + ) + .expect("valid corpus manifest"); + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + for (index, source) in manifest.artifacts.into_iter().enumerate() { + let coverage = match source.capture_state.as_str() { + "captured" => SccmCoverageState::Captured, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "capped" => SccmCoverageState::Capped, + "skipped" => SccmCoverageState::Skipped, + "unsupported" => SccmCoverageState::Unsupported, + "parseFailed" => SccmCoverageState::ParseFailed, + other => panic!("unsupported corpus coverage {other}"), + }; + let rotation = match source.rotation.kind.as_str() { + "current" => SccmRotation::Current, + "lo" | "loUnderscore" => SccmRotation::LoUnderscore, + other => panic!("unsupported corpus rotation {other}"), + }; + let artifact_id = format!("fixture-update-numbered-{:02}", index + 1); + let classified = classify_artifact_name(&source.original_basename, SccmRole::Client); + let eligible_payload = coverage == SccmCoverageState::Captured + && source.rotation.fragment_complete == Some(true) + && classified.supported_for_diagnosis + && classified.uses_ccm_records; + let bytes = eligible_payload.then(|| { + fs::read( + scenario_dir.join( + source + .relative_path + .as_deref() + .expect("captured corpus relative path"), + ), + ) + .expect("captured corpus bytes") + }); + let relative_path = source.relative_path.or_else(|| { + matches!( + coverage, + SccmCoverageState::Captured + | SccmCoverageState::Capped + | SccmCoverageState::ParseFailed + ) + .then(|| { + let rotation_segment = match &rotation { + SccmRotation::LoUnderscore => "lo", + _ => "current", + }; + format!( + "evidence/{}/{rotation_segment}/{}", + source.design_only_catalog.entry_id, source.original_basename + ) + }) + }); + artifacts.push(SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: source.original_basename, + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: if classified.uses_ccm_records { + Some("5.00.9128.1000".to_owned()) + } else { + source.source_version + }, + collected_at_utc: source.captured_utc, + rotation, + coverage, + encoding: source.encoding, + }, + path_fingerprint: Some(format!("synthetic:numbered-{:02}", index + 1)), + rotation_lineage: None, + relative_path, + fragment_complete: Some(source.rotation.fragment_complete.unwrap_or(false)), + declared_byte_length: bytes.as_ref().map(|bytes| bytes.len() as u64), + content_sha256: bytes.as_ref().map(|bytes| sha256(bytes)), + }); + if let Some(bytes) = bytes { + payloads.push( + SccmClientCapturedPayload::new(artifact_id, bytes) + .map_err(|error| error.to_string())?, + ); + } + } + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).map_err(|error| error.to_string())?; + admit_client_evidence(&bundle, &assessment, &payloads).map_err(|error| error.to_string()) +} + +#[test] +fn scan_failure_uses_sealed_exact_key_evidence_without_server_cause() { + let admitted = admitted_scan(&format!( + "UpdateId={UPDATE_ID} CIId={CI_ID} ScanResult=failed ErrorCode=0x8024401c" + )); + + let analysis = analyze_client_updates(&admitted).expect("update analysis"); + + assert_eq!(analysis.transactions.len(), 1); + let transaction = &analysis.transactions[0]; + assert_eq!(transaction.key.update_id, UPDATE_ID); + assert_eq!(transaction.key.ci_id, CI_ID); + assert_eq!(transaction.phase, SccmClientUpdatePhase::Scan); + assert_eq!(transaction.state, SccmClientUpdateState::Failed); + assert_eq!( + serde_json::to_value(transaction.classification).expect("classification"), + "symptom" + ); + assert_eq!(transaction.last_successful_phase, None); + assert_eq!(transaction.evidence.len(), 1); + assert_eq!( + transaction.evidence[0].artifact_id, + "fixture-update-failure" + ); + assert!(!analysis.correlation_handoff.performed); + assert!(!analysis.correlation_handoff.server_cause_claimed); + assert!(!analysis.correlation_handoff.time_only_eligible); +} + +#[test] +fn full_success_proves_all_eight_phases_without_cross_side_correlation() { + let records = vec![ + Record { + id: "update-a", + basename: "ScanAgent.log", + group: "client-updates", + component: "ScanAgent", + time: "02:00:00.000", + message: keyed(UPDATE_ID, CI_ID, "Scan succeeded"), + }, + Record { + id: "update-b", + basename: "WUAHandler.log", + group: "client-updates", + component: "WUAHandler", + time: "02:00:01.000", + message: keyed(UPDATE_ID, CI_ID, "Evaluate applicable"), + }, + Record { + id: "update-location", + basename: "LocationServices.log", + group: "client-location-services-shared", + component: "LocationServices", + time: "02:00:02.000", + message: keyed(UPDATE_ID, CI_ID, "LocateSup selected"), + }, + Record { + id: "update-download", + basename: "DataTransferService.log", + group: "client-content", + component: "DataTransferService", + time: "02:00:03.000", + message: keyed(UPDATE_ID, CI_ID, "Download succeeded"), + }, + Record { + id: "update-c", + basename: "UpdatesStore.log", + group: "client-updates", + component: "UpdatesStore", + time: "02:00:04.000", + message: keyed(UPDATE_ID, CI_ID, "MaintenanceWindow open"), + }, + Record { + id: "update-success", + basename: "UpdatesHandler.log", + group: "client-updates", + component: "UpdatesHandler", + time: "02:00:05.000", + message: keyed(UPDATE_ID, CI_ID, "Install succeeded"), + }, + Record { + id: "update-recovery", + basename: "UpdatesDeployment.log", + group: "client-updates", + component: "UpdatesDeployment", + time: "02:00:06.000", + message: keyed(UPDATE_ID, CI_ID, "Reboot complete"), + }, + Record { + id: "update-report", + basename: "StateMessage.log", + group: "client-policy-state", + component: "StateMessage", + time: "02:00:07.000", + message: keyed(UPDATE_ID, CI_ID, "Report succeeded"), + }, + ]; + + let analysis = analyze_client_updates(&admitted(&records)).expect("update analysis"); + assert_eq!(analysis.transactions.len(), 1); + let transaction = &analysis.transactions[0]; + assert_eq!(transaction.phase, SccmClientUpdatePhase::Report); + assert_eq!(transaction.state, SccmClientUpdateState::Succeeded); + assert_eq!( + transaction.last_successful_phase, + Some(SccmClientUpdatePhase::Report) + ); + assert_eq!(transaction.evidence.len(), 8); + assert!(analysis.findings.is_empty()); + assert!(!analysis.correlation_handoff.performed); + assert!(!analysis.correlation_handoff.server_cause_claimed); + assert!(analysis.correlation_handoff.emitted_counterpart_ready_fact); + assert_eq!( + analysis.correlation_handoff.counterpart_ready_facts.len(), + 1 + ); + let counterpart = &analysis.correlation_handoff.counterpart_ready_facts[0]; + assert_eq!(counterpart.update_id, UPDATE_ID); + assert_eq!(counterpart.ci_id, CI_ID); + assert_eq!(counterpart.phase, SccmClientUpdatePhase::LocateSup); + assert_eq!( + counterpart.key_confidence, + cmtraceopen_parser::sccm::SccmKeyConfidence::Low + ); + assert_eq!( + counterpart.timestamp_provenance.normalized_utc, + "2026-07-30T02:00:02.000Z" + ); + assert_eq!(counterpart.evidence.artifact_id, "fixture-update-location"); + assert!(!counterpart.correlation_eligible); + assert!(!counterpart.time_only_eligible); + assert!( + !analysis + .correlation_handoff + .topology_compatibility_evaluated + ); +} + +#[test] +fn maintenance_window_defer_is_not_a_failure() { + let records = vec![ + Record { + id: "update-a", + basename: "ScanAgent.log", + group: "client-updates", + component: "ScanAgent", + time: "07:00:00.000", + message: keyed(UPDATE_ID, CI_ID, "Scan succeeded"), + }, + Record { + id: "update-b", + basename: "WUAHandler.log", + group: "client-updates", + component: "WUAHandler", + time: "07:00:01.000", + message: keyed(UPDATE_ID, CI_ID, "Evaluate applicable"), + }, + Record { + id: "update-location", + basename: "LocationServices.log", + group: "client-location-services-shared", + component: "LocationServices", + time: "07:00:02.000", + message: keyed(UPDATE_ID, CI_ID, "LocateSup selected"), + }, + Record { + id: "update-download", + basename: "DataTransferService.log", + group: "client-content", + component: "DataTransferService", + time: "07:00:03.000", + message: keyed(UPDATE_ID, CI_ID, "Download succeeded"), + }, + Record { + id: "update-c", + basename: "UpdatesDeployment.log", + group: "client-updates", + component: "UpdatesDeployment", + time: "07:00:04.000", + message: keyed( + UPDATE_ID, + CI_ID, + "MaintenanceWindow deferred next-context unavailable", + ), + }, + ]; + + let analysis = analyze_client_updates(&admitted(&records)).expect("update analysis"); + let transaction = &analysis.transactions[0]; + assert_eq!(transaction.phase, SccmClientUpdatePhase::MaintenanceWindow); + assert_eq!(transaction.state, SccmClientUpdateState::BlockedOrDeferred); + assert_eq!( + transaction.last_successful_phase, + Some(SccmClientUpdatePhase::Download) + ); + assert_eq!( + transaction.coverage_gap_artifact_ids, + ["client-maintenance-window"] + ); + assert_eq!( + transaction + .next_artifact + .as_ref() + .map(|request| request.logical_artifact_id.as_str()), + Some("client-maintenance-window") + ); + assert_eq!( + analysis.findings[0].class, + cmtraceopen_parser::sccm::SccmFindingClass::BlockedOrDeferred + ); +} + +#[test] +fn evaluate_only_does_not_infer_a_missing_sup_without_a_declared_gap() { + let records = vec![ + Record { + id: "update-a", + basename: "ScanAgent.log", + group: "client-updates", + component: "ScanAgent", + time: "03:00:00.000", + message: keyed(UPDATE_ID, CI_ID, "Scan succeeded"), + }, + Record { + id: "update-b", + basename: "WUAHandler.log", + group: "client-updates", + component: "WUAHandler", + time: "03:00:01.000", + message: keyed(UPDATE_ID, CI_ID, "Evaluate applicable"), + }, + ]; + + let analysis = analyze_client_updates(&admitted(&records)).expect("update analysis"); + let transaction = &analysis.transactions[0]; + assert_eq!(transaction.phase, SccmClientUpdatePhase::Evaluate); + assert_eq!(transaction.state, SccmClientUpdateState::Succeeded); + assert_eq!( + transaction.last_successful_phase, + Some(SccmClientUpdatePhase::Evaluate) + ); + assert!(transaction.coverage_gap_artifact_ids.is_empty()); + assert!(transaction.next_artifact.is_none()); + assert!(!analysis.correlation_handoff.server_cause_claimed); +} + +#[test] +fn same_minute_updates_remain_separate_and_input_order_is_deterministic() { + let other_update = "32300000-0000-0000-0000-000000000099"; + let records = vec![ + Record { + id: "update-a", + basename: "ScanAgent.log", + group: "client-updates", + component: "ScanAgent", + time: "12:00:00.000", + message: keyed(UPDATE_ID, CI_ID, "Scan succeeded"), + }, + Record { + id: "update-b", + basename: "WUAHandler.log", + group: "client-updates", + component: "WUAHandler", + time: "12:00:00.000", + message: keyed(other_update, "323099", "Evaluate terminal failure"), + }, + ]; + let original = analyze_client_updates(&admitted(&records)).expect("ordered analysis"); + let mut reversed_records = records.clone(); + reversed_records.reverse(); + let reversed = analyze_client_updates(&admitted(&reversed_records)).expect("reversed analysis"); + + assert_eq!(original.transactions.len(), 2); + assert_eq!( + serde_json::to_value(original).expect("serialize"), + serde_json::to_value(reversed).expect("serialize") + ); +} + +#[test] +fn counterpart_fact_requires_location_services_and_every_exact_field() { + let missing_sup = Record { + id: "update-a", + basename: "LocationServices.log", + group: "client-location-services-shared", + component: "LocationServices", + time: "05:00:00.000", + message: format!( + "LocateSup selected UpdateId={{{UPDATE_ID}}} CIId={CI_ID} \ + ContentId=CONTENT-{CI_ID} UpdateJobId=JOB-{CI_ID} \ + ClientHandle=safe:client:{CI_ID} SiteCode=LAB" + ), + }; + let wrong_source = Record { + id: "update-b", + basename: "UpdatesDeployment.log", + group: "client-updates", + component: "UpdatesDeployment", + time: "05:00:01.000", + message: keyed(UPDATE_ID, CI_ID, "LocateSup selected"), + }; + let raw_host = Record { + id: "update-c", + basename: "LocationServices.log", + group: "client-location-services-shared", + component: "LocationServices", + time: "05:00:02.000", + message: keyed(UPDATE_ID, CI_ID, "LocateSup selected") + .replace("safe:sup:lab", "LAB-SUP-01"), + }; + + for record in [missing_sup, wrong_source, raw_host] { + let analysis = analyze_client_updates(&admitted(&[record])).expect("update analysis"); + assert!(!analysis.correlation_handoff.emitted_counterpart_ready_fact); + assert!(analysis + .correlation_handoff + .counterpart_ready_facts + .is_empty()); + } +} + +#[test] +fn later_same_phase_success_recovers_an_earlier_terminal_marker() { + let records = vec![ + Record { + id: "update-a", + basename: "UpdatesHandler.log", + group: "client-updates", + component: "UpdatesHandler", + time: "06:00:00.000", + message: keyed(UPDATE_ID, CI_ID, "Install terminal failure"), + }, + Record { + id: "update-b", + basename: "UpdatesDeployment.log", + group: "client-updates", + component: "UpdatesDeployment", + time: "06:00:01.000", + message: keyed(UPDATE_ID, CI_ID, "Install succeeded"), + }, + ]; + + let analysis = analyze_client_updates(&admitted(&records)).expect("update analysis"); + let transaction = &analysis.transactions[0]; + assert_eq!(transaction.phase, SccmClientUpdatePhase::Install); + assert_eq!(transaction.state, SccmClientUpdateState::Succeeded); + assert_eq!( + transaction.last_successful_phase, + Some(SccmClientUpdatePhase::Install) + ); + assert!(analysis.findings.is_empty()); +} + +#[test] +fn canonical_capture_gap_keeps_the_transaction_incomplete() { + let record = Record { + id: "update-a", + basename: "ScanAgent.log", + group: "client-updates", + component: "ScanAgent", + time: "09:00:00.000", + message: keyed(UPDATE_ID, CI_ID, "Scan succeeded"), + }; + let artifact_id = format!("fixture-{}", record.id); + let bytes = format!( + "\n", + record.message, record.time, record.component + ) + .into_bytes(); + let mut bundle = SccmClientIntakeBundle { + artifacts: vec![SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: artifact_id.clone(), + display_name: record.basename.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T23:59:59Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some("synthetic-update-a".to_owned()), + rotation_lineage: None, + relative_path: Some("evidence/client-updates/current/ScanAgent.log".to_owned()), + fragment_complete: Some(true), + declared_byte_length: Some(bytes.len() as u64), + content_sha256: Some(sha256(&bytes)), + }], + capture_gaps: vec![SccmClientIntakeCaptureGap { + artifact_id: "fixture-capped-rotation".to_owned(), + basename: "UpdatesHandler.log".to_owned(), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Capped, + path_fingerprint: "synthetic:capped-rotation".to_owned(), + rotation_lineage: "synthetic:capped-rotation".to_owned(), + }], + }; + let assessment = assess_client_intake(&bundle).expect("canonical gap intake"); + let admitted = admit_client_evidence( + &bundle, + &assessment, + &[ + SccmClientCapturedPayload::new(artifact_id.clone(), bytes.clone()) + .expect("bounded payload"), + ], + ) + .expect("sealed evidence with canonical gap"); + + let analysis = analyze_client_updates(&admitted).expect("update analysis"); + assert_eq!( + analysis.transactions[0].state, + SccmClientUpdateState::Incomplete + ); + assert_eq!( + analysis.transactions[0].phase, + SccmClientUpdatePhase::Install + ); + assert_eq!(analysis.findings.len(), 1); + + bundle.capture_gaps.clear(); + bundle.artifacts.push(SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: "fixture-update-b".to_owned(), + display_name: "ScanAgent.lo_".to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1000".to_owned()), + collected_at_utc: Some("2026-07-30T23:59:59Z".to_owned()), + rotation: SccmRotation::LoUnderscore, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some("synthetic-update-b".to_owned()), + rotation_lineage: None, + relative_path: Some("evidence/client-updates/lo/ScanAgent.lo_".to_owned()), + fragment_complete: Some(false), + declared_byte_length: None, + content_sha256: None, + }); + let assessment = assess_client_intake(&bundle).expect("canonical rotation gap intake"); + let admitted = admit_client_evidence( + &bundle, + &assessment, + &[SccmClientCapturedPayload::new(artifact_id, bytes).expect("bounded payload")], + ) + .expect("sealed evidence with canonical rotation gap"); + let analysis = analyze_client_updates(&admitted).expect("rotation gap analysis"); + let updates = analysis + .coverage + .iter() + .find(|coverage| coverage.logical_artifact_id == "client-updates") + .expect("updates coverage"); + assert_eq!( + serde_json::to_value(updates).expect("coverage")["state"], + "partial" + ); +} + +#[test] +fn partial_rotation_is_reported_as_partial_coverage() { + let admitted = corpus_admitted("rotation-boundary").expect("rotation corpus admission"); + let analysis = analyze_client_updates(&admitted).expect("rotation analysis"); + let updates = analysis + .coverage + .iter() + .find(|coverage| coverage.logical_artifact_id == "client-updates") + .expect("updates coverage"); + assert_eq!( + serde_json::to_value(updates).expect("coverage")["state"], + "partial" + ); +} + +#[test] +fn complete_rotated_source_uses_its_canonical_basename_authority() { + let rotated = Record { + id: "update-a", + basename: "ScanAgent.lo_", + group: "client-updates", + component: "ScanAgent", + time: "09:30:00.000", + message: keyed(UPDATE_ID, CI_ID, "Scan succeeded"), + }; + + let analysis = analyze_client_updates(&admitted(&[rotated])).expect("rotated update analysis"); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!(analysis.transactions[0].phase, SccmClientUpdatePhase::Scan); +} + +#[test] +fn counterpart_rejects_component_spoofing_and_privacy_bearing_handles() { + let spoofed_source = Record { + id: "update-a", + basename: "UpdatesDeployment.log", + group: "client-updates", + component: "LocationServices", + time: "10:00:00.000", + message: keyed(UPDATE_ID, CI_ID, "LocateSup selected"), + }; + let privacy_client = Record { + id: "update-b", + basename: "LocationServices.log", + group: "client-location-services-shared", + component: "LocationServices", + time: "10:00:01.000", + message: keyed(UPDATE_ID, CI_ID, "LocateSup selected") + .replace(&format!("safe:client:{CI_ID}"), "safe:Adam.Gell"), + }; + let privacy_sup = Record { + id: "update-c", + basename: "LocationServices.log", + group: "client-location-services-shared", + component: "LocationServices", + time: "10:00:02.000", + message: keyed(UPDATE_ID, CI_ID, "LocateSup selected") + .replace("safe:sup:lab", "safe:sup:prod.contoso.com"), + }; + + let spoofed = analyze_client_updates(&admitted(&[spoofed_source])).expect("update analysis"); + assert!(spoofed.transactions.is_empty()); + assert!(spoofed + .correlation_handoff + .counterpart_ready_facts + .is_empty()); + + for record in [privacy_client, privacy_sup] { + let analysis = analyze_client_updates(&admitted(&[record])).expect("update analysis"); + assert!(analysis + .correlation_handoff + .counterpart_ready_facts + .is_empty()); + } +} + +#[test] +fn non_update_physical_source_cannot_mint_an_update_phase() { + let spoofed = Record { + id: "update-a", + basename: "AppEnforce.log", + group: "client-app-enforce", + component: "ScanAgent", + time: "10:30:00.000", + message: keyed(UPDATE_ID, CI_ID, "Scan terminal failure"), + }; + + let analysis = analyze_client_updates(&admitted(&[spoofed])).expect("update analysis"); + assert!(analysis.transactions.is_empty()); +} + +#[test] +fn full_subject_tuple_prevents_false_phase_merges() { + let mut scan = keyed(UPDATE_ID, CI_ID, "Scan succeeded"); + scan = scan.replace(&format!("JOB-{CI_ID}"), "JOB-A"); + let mut evaluate = keyed(UPDATE_ID, CI_ID, "Evaluate applicable"); + evaluate = evaluate.replace(&format!("JOB-{CI_ID}"), "JOB-B"); + let records = [ + Record { + id: "update-a", + basename: "ScanAgent.log", + group: "client-updates", + component: "ScanAgent", + time: "11:00:00.000", + message: scan, + }, + Record { + id: "update-b", + basename: "WUAHandler.log", + group: "client-updates", + component: "WUAHandler", + time: "11:00:01.000", + message: evaluate, + }, + ]; + + let analysis = analyze_client_updates(&admitted(&records)).expect("update analysis"); + assert_eq!(analysis.transactions.len(), 2); + assert!(analysis.transactions.iter().all(|transaction| { + transaction.phase == SccmClientUpdatePhase::Scan + || transaction.phase == SccmClientUpdatePhase::Evaluate + })); +} + +#[test] +fn equal_time_opposing_outcomes_are_contradictory() { + let records = [ + Record { + id: "update-a", + basename: "UpdatesHandler.log", + group: "client-updates", + component: "UpdatesHandler", + time: "12:30:00.000", + message: keyed(UPDATE_ID, CI_ID, "Install terminal failure"), + }, + Record { + id: "update-b", + basename: "UpdatesDeployment.log", + group: "client-updates", + component: "UpdatesDeployment", + time: "12:30:00.000", + message: keyed(UPDATE_ID, CI_ID, "Install succeeded"), + }, + ]; + + let analysis = analyze_client_updates(&admitted(&records)).expect("update analysis"); + assert_eq!( + analysis.transactions[0].state, + SccmClientUpdateState::Contradictory + ); +} + +#[test] +fn complete_non_ccm_supplemental_coverage_remains_captured() { + let admitted = corpus_admitted("supplemental-conflict").expect("supplemental corpus admission"); + let analysis = analyze_client_updates(&admitted).expect("supplemental analysis"); + let supplemental = analysis + .coverage + .iter() + .find(|coverage| coverage.logical_artifact_id == "client-windows-update-supplemental") + .expect("supplemental coverage"); + assert_eq!( + serde_json::to_value(supplemental).expect("coverage")["state"], + "captured" + ); +} + +#[test] +fn public_ids_include_the_full_stable_subject_discriminator() { + let records = [ + Record { + id: "update-a", + basename: "ScanAgent.log", + group: "client-updates", + component: "ScanAgent", + time: "13:00:00.000", + message: keyed(UPDATE_ID, "323010", "Scan terminal failure"), + }, + Record { + id: "update-b", + basename: "WUAHandler.log", + group: "client-updates", + component: "WUAHandler", + time: "13:00:01.000", + message: keyed(UPDATE_ID, "323011", "Evaluate terminal failure"), + }, + ]; + + let analysis = analyze_client_updates(&admitted(&records)).expect("update analysis"); + assert_eq!(analysis.transactions.len(), 2); + assert_ne!( + analysis.transactions[0].transaction_id, + analysis.transactions[1].transaction_id + ); + assert_eq!(analysis.findings.len(), 2); + assert_ne!( + analysis.findings[0].finding_id, + analysis.findings[1].finding_id + ); +} + +#[test] +fn all_committed_update_scenarios_execute_through_the_exported_analyzer() { + let mut scenarios = fs::read_dir(corpus_root()) + .expect("updates corpus directory") + .map(|entry| entry.expect("scenario entry")) + .filter(|entry| entry.file_type().expect("scenario type").is_dir()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(); + scenarios.sort(); + assert_eq!( + scenarios.len(), + 17, + "the complete committed corpus is executable" + ); + + for scenario in scenarios { + let expected: Value = serde_json::from_slice( + &fs::read(corpus_root().join(&scenario).join("expected.json")) + .expect("scenario expected contract"), + ) + .expect("valid scenario expected contract"); + let admitted = corpus_admitted(&scenario) + .unwrap_or_else(|error| panic!("{scenario}: sealed corpus admission: {error}")); + let analysis = analyze_client_updates(&admitted) + .unwrap_or_else(|error| panic!("{scenario}: exported analyzer: {error}")); + let actual = serde_json::to_value(&analysis).expect("serializable analysis"); + + assert_eq!( + actual["transactions"].as_array().map(Vec::len), + expected["transactions"].as_array().map(Vec::len), + "{scenario}: transaction count" + ); + assert_eq!( + actual["findings"].as_array().map(Vec::len), + expected["findings"].as_array().map(Vec::len), + "{scenario}: finding count" + ); + for expected_transaction in expected["transactions"] + .as_array() + .expect("expected transactions") + { + let update_id = &expected_transaction["key"]["updateId"]; + let actual_transaction = actual["transactions"] + .as_array() + .expect("actual transactions") + .iter() + .find(|transaction| transaction["key"]["updateId"] == *update_id) + .unwrap_or_else(|| panic!("{scenario}: missing transaction {update_id}")); + for field in ["phase", "state", "lastSuccessfulPhase"] { + assert_eq!( + actual_transaction[field], expected_transaction[field], + "{scenario}: {field}" + ); + } + if actual_transaction["state"] == "failed" { + assert_eq!( + actual_transaction["classification"], "symptom", + "{scenario}: experimental keys cannot establish causal failure" + ); + } + assert_eq!( + actual_transaction["nextArtifact"]["logicalArtifactId"], + expected_transaction["nextArtifact"]["logicalArtifactId"], + "{scenario}: bounded next artifact" + ); + } + assert_eq!( + actual["correlationHandoff"]["counterpartReadyFacts"] + .as_array() + .map(Vec::len), + expected["correlationHandoff"]["counterpartReadyFacts"] + .as_array() + .map(Vec::len), + "{scenario}: counterpart-ready fact count" + ); + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs new file mode 100644 index 000000000..ece82cc80 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -0,0 +1,3868 @@ +use chrono::{DateTime, SecondsFormat, Utc}; +use cmtraceopen_parser::{ + models::log_entry::LogFormat, + parser::{parse_content_with_selection, ResolvedParser}, + sccm::{ + extract_keys, normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmEvidence, + SccmExtractionGapKind, SccmExtractionProfile, SccmKeyConfidence, SccmRole, SccmRotation, + SccmTimeOrderingState, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, + }, +}; +use serde_json::Value; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, +}; + +const STATE_CHAIN: [&str; 8] = [ + "scan", + "evaluate", + "locateSup", + "download", + "maintenanceWindow", + "install", + "reboot", + "report", +]; +const EXPECTED_ARTIFACTS: usize = 51; +const EXPECTED_PHYSICAL_FILES: usize = 43; +const EXPECTED_PHYSICAL_BYTES: u64 = 23_142; +const EXPECTED_PHYSICAL_LINES: u64 = 61; +const EXPECTED_COMPLETE_CCM_RECORDS: usize = 57; +const EXPECTED_PARTIAL_FILES: usize = 2; +const EXPECTED_CAPPED_FILES: usize = 1; +const EXPECTED_CORPUS_FNV1A64: u64 = 0x1ff6_72e5_1adb_eb52; +const EXPECTED_CORPUS_SHA256: &str = + "b7670821f385f90eb0178528480307f617c508c28abacf21e927d30ed3bdffef"; +const EXPECTED_CAPPED_CONTENT: &[u8] = b", + state: &'static str, + classification: &'static str, + confidence_ceiling: &'static str, + last_successful_phase: Option<&'static str>, + next_artifact: Option<&'static str>, + coverage: &'static [(&'static str, &'static str)], + counterpart_facts: usize, +} + +const SCENARIOS: [ScenarioContract; 17] = [ + ScenarioContract { + name: "access-denied", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("install"), + state: "incomplete", + classification: "insufficientEvidence", + confidence_ceiling: "medium", + last_successful_phase: Some("scan"), + next_artifact: Some("client-updates"), + coverage: &[("client-updates", "accessDenied")], + counterpart_facts: 0, + }, + ScenarioContract { + name: "capped", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("download"), + state: "incomplete", + classification: "insufficientEvidence", + confidence_ceiling: "medium", + last_successful_phase: Some("locateSup"), + next_artifact: Some("client-content"), + coverage: &[ + ("client-content", "capped"), + ("client-location-services-shared", "captured"), + ("client-updates", "captured"), + ], + counterpart_facts: 1, + }, + ScenarioContract { + name: "content-failure", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("download"), + state: "failed", + classification: "confirmedFailure", + confidence_ceiling: "high", + last_successful_phase: Some("locateSup"), + next_artifact: None, + coverage: &[ + ("client-content", "captured"), + ("client-location-services-shared", "captured"), + ("client-updates", "captured"), + ], + counterpart_facts: 1, + }, + ScenarioContract { + name: "evaluation-failure", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("evaluate"), + state: "failed", + classification: "confirmedFailure", + confidence_ceiling: "high", + last_successful_phase: Some("scan"), + next_artifact: None, + coverage: &[("client-updates", "captured")], + counterpart_facts: 0, + }, + ScenarioContract { + name: "incomplete", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("maintenanceWindow"), + state: "incomplete", + classification: "insufficientEvidence", + confidence_ceiling: "medium", + last_successful_phase: Some("download"), + next_artifact: Some("client-maintenance-window"), + coverage: &[ + ("client-maintenance-window", "absent"), + ("client-policy-state", "absent"), + ("client-reboot", "absent"), + ("client-updates", "captured"), + ], + counterpart_facts: 0, + }, + ScenarioContract { + name: "install-failure", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("install"), + state: "failed", + classification: "confirmedFailure", + confidence_ceiling: "high", + last_successful_phase: Some("maintenanceWindow"), + next_artifact: None, + coverage: &[ + ("client-content", "captured"), + ("client-location-services-shared", "captured"), + ("client-updates", "captured"), + ], + counterpart_facts: 1, + }, + ScenarioContract { + name: "invalid-offset", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("evaluate"), + state: "contradictory", + classification: "insufficientEvidence", + confidence_ceiling: "low", + last_successful_phase: Some("scan"), + next_artifact: Some("client-updates"), + coverage: &[("client-updates", "captured")], + counterpart_facts: 0, + }, + ScenarioContract { + name: "maintenance-window", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("maintenanceWindow"), + state: "blockedOrDeferred", + classification: "blockedOrDeferred", + confidence_ceiling: "medium", + last_successful_phase: Some("download"), + next_artifact: Some("client-maintenance-window"), + coverage: &[ + ("client-content", "captured"), + ("client-location-services-shared", "captured"), + ("client-maintenance-window", "captured"), + ("client-updates", "captured"), + ], + counterpart_facts: 1, + }, + ScenarioContract { + name: "malformed", + transactions: 0, + observations: 1, + findings: 1, + phase: None, + state: "malformed", + classification: "lowConfidenceSymptom", + confidence_ceiling: "low", + last_successful_phase: None, + next_artifact: Some("client-updates"), + coverage: &[ + ("client-updates", "parseFailed"), + ("client-windows-update-supplemental", "unsupported"), + ], + counterpart_facts: 0, + }, + ScenarioContract { + name: "no-sup", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("locateSup"), + state: "incomplete", + classification: "insufficientEvidence", + confidence_ceiling: "medium", + last_successful_phase: Some("evaluate"), + next_artifact: Some("client-location-services-shared"), + coverage: &[ + ("client-location-services-shared", "absent"), + ("client-updates", "captured"), + ], + counterpart_facts: 0, + }, + ScenarioContract { + name: "reboot-pending", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("reboot"), + state: "blockedOrDeferred", + classification: "blockedOrDeferred", + confidence_ceiling: "medium", + last_successful_phase: Some("install"), + next_artifact: Some("client-reboot"), + coverage: &[ + ("client-location-services-shared", "captured"), + ("client-reboot", "captured"), + ("client-updates", "captured"), + ], + counterpart_facts: 1, + }, + ScenarioContract { + name: "reporting-failure", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("report"), + state: "failed", + classification: "confirmedFailure", + confidence_ceiling: "high", + last_successful_phase: Some("reboot"), + next_artifact: None, + coverage: &[ + ("client-location-services-shared", "captured"), + ("client-policy-state", "captured"), + ("client-updates", "captured"), + ], + counterpart_facts: 1, + }, + ScenarioContract { + name: "rotation-boundary", + transactions: 0, + observations: 1, + findings: 1, + phase: None, + state: "incomplete", + classification: "insufficientEvidence", + confidence_ceiling: "low", + last_successful_phase: None, + next_artifact: Some("client-updates"), + coverage: &[("client-updates", "partial")], + counterpart_facts: 0, + }, + ScenarioContract { + name: "same-minute-separate", + transactions: 2, + observations: 0, + findings: 1, + phase: Some("report"), + state: "succeeded", + classification: "success", + confidence_ceiling: "high", + last_successful_phase: Some("report"), + next_artifact: None, + coverage: &[("client-updates", "captured")], + counterpart_facts: 0, + }, + ScenarioContract { + name: "scan-failure", + transactions: 1, + observations: 0, + findings: 1, + phase: Some("scan"), + state: "failed", + classification: "confirmedFailure", + confidence_ceiling: "high", + last_successful_phase: None, + next_artifact: None, + coverage: &[("client-updates", "captured")], + counterpart_facts: 0, + }, + ScenarioContract { + name: "success", + transactions: 1, + observations: 0, + findings: 0, + phase: Some("report"), + state: "succeeded", + classification: "success", + confidence_ceiling: "high", + last_successful_phase: Some("report"), + next_artifact: None, + coverage: &[ + ("client-content", "captured"), + ("client-location-services-shared", "captured"), + ("client-maintenance-window", "captured"), + ("client-policy-state", "captured"), + ("client-reboot", "captured"), + ("client-updates", "captured"), + ("client-windows-update-supplemental", "skipped"), + ], + counterpart_facts: 1, + }, + ScenarioContract { + name: "supplemental-conflict", + transactions: 1, + observations: 1, + findings: 1, + phase: Some("install"), + state: "contradictory", + classification: "lowConfidenceSymptom", + confidence_ceiling: "low", + last_successful_phase: None, + next_artifact: Some("client-windows-update-supplemental"), + coverage: &[ + ("client-updates", "captured"), + ("client-windows-update-supplemental", "captured"), + ], + counterpart_facts: 0, + }, +]; + +fn updates_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/updates") +} + +fn read_json(path: &Path) -> Value { + let contents = std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("{} must be readable: {error}", path.display())); + serde_json::from_str(&contents) + .unwrap_or_else(|error| panic!("{} must contain valid JSON: {error}", path.display())) +} + +fn scenario_directories() -> Vec { + let mut scenarios = std::fs::read_dir(updates_root()) + .expect("the #323 updates fixture root must exist") + .map(|entry| entry.expect("updates directory entry is readable").path()) + .filter(|path| path.is_dir()) + .map(|path| { + path.file_name() + .expect("scenario directory has a name") + .to_string_lossy() + .into_owned() + }) + .collect::>(); + scenarios.sort(); + scenarios +} + +fn json_string(value: &Value, field: &str) -> Result { + value[field] + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{field} must be a string")) +} + +fn optional_json_string(value: &Value, field: &str) -> Option { + value[field].as_str().map(str::to_owned) +} + +fn string_array(values: &Value, field: &str) -> Result, String> { + values[field] + .as_array() + .ok_or_else(|| format!("{field} must be an array"))? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{field} values must be strings")) + }) + .collect() +} + +fn subject<'a>(expected: &'a Value, contract: &ScenarioContract) -> Option<&'a Value> { + if contract.observations > 0 { + expected["sourceLocalObservations"].as_array()?.first() + } else { + expected["transactions"].as_array()?.first() + } +} + +fn manifest_identity_failures(manifest: &Value, scenario: &str) -> Vec { + let mut failures = Vec::new(); + if manifest["sccmManifestVersion"] != 1 + || manifest["proposalOnly"] != true + || manifest["syntheticFixture"] != true + || manifest["bundle"]["role"] != "client" + || manifest["bundle"]["workflow"] != "updates" + || manifest["bundle"]["captureHost"] != "LAB-CLIENT-01" + || manifest["bundle"]["siteCode"] != "LAB" + { + failures.push(format!( + "{scenario}: manifest identity/proposal boundary drifted" + )); + } + failures +} + +fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> Vec { + let mut failures = Vec::new(); + let scenario = contract.name; + + if expected["contractState"] != "proposedPending318" { + failures.push(format!( + "{scenario}: contractState must remain proposedPending318" + )); + } + if expected["workflow"] != "updates" || expected["scenario"] != scenario { + failures.push(format!("{scenario}: workflow/scenario identity drifted")); + } + let state_chain = match expected["stateChain"].as_array() { + Some(values) => values + .iter() + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect::>(), + None => { + failures.push(format!("{scenario}: stateChain must be an array")); + Vec::new() + } + }; + if state_chain != STATE_CHAIN { + failures.push(format!("{scenario}: state chain drifted: {state_chain:?}")); + } + let analysis = &expected["analysisContract"]; + if analysis["independentReducer"] != true + || analysis["consumesOtherReducerOutput"] != false + || analysis["policyOutputRequired"] != false + || analysis["crossSideCorrelationPerformed"] != false + { + failures.push(format!( + "{scenario}: reducer must remain independent and client-only" + )); + } + if expected["reorderedInputDeterministic"] != true { + failures.push(format!( + "{scenario}: input reordering must be deterministic" + )); + } + + let Some(transactions) = expected["transactions"].as_array() else { + failures.push(format!("{scenario}: transactions must be an array")); + return failures; + }; + let Some(observations) = expected["sourceLocalObservations"].as_array() else { + failures.push(format!( + "{scenario}: sourceLocalObservations must be an array" + )); + return failures; + }; + let Some(findings) = expected["findings"].as_array() else { + failures.push(format!("{scenario}: findings must be an array")); + return failures; + }; + if transactions.len() != contract.transactions + || observations.len() != contract.observations + || findings.len() != contract.findings + { + failures.push(format!( + "{scenario}: expected {}/{}/{} transactions/observations/findings, got {}/{}/{}", + contract.transactions, + contract.observations, + contract.findings, + transactions.len(), + observations.len(), + findings.len() + )); + } + let mut transaction_ids = Vec::new(); + for transaction in transactions { + match json_string(transaction, "transactionId") { + Ok(transaction_id) => transaction_ids.push(transaction_id), + Err(error) => failures.push(format!("{scenario}: transaction {error}")), + } + } + let mut sorted_transaction_ids = transaction_ids.clone(); + sorted_transaction_ids.sort(); + if transaction_ids != sorted_transaction_ids + || transaction_ids.iter().collect::>().len() != transaction_ids.len() + { + failures.push(format!( + "{scenario}: transaction IDs must be unique and sorted" + )); + } + for transaction in transactions { + if transaction["key"]["confidence"] != "exact" + || transaction["key"]["extractionProfileId"] != "updates-client-5.00.test-v1" + || transaction["evidence"].as_array().is_none_or(Vec::is_empty) + { + failures.push(format!( + "{scenario}: every transaction needs an exact profiled key and evidence" + )); + } + } + for observation in observations { + if !observation["key"].is_null() + || observation["keyConfidence"] != "none" + || observation["correlationEligible"] != false + || observation["evidence"].as_array().is_none_or(Vec::is_empty) + { + failures.push(format!( + "{scenario}: source-local observations must stay keyless and uncorrelatable" + )); + } + if !observation["lastSuccessfulPhase"].is_null() { + failures.push(format!( + "{scenario}: keyless observation cannot claim a lastSuccessfulPhase" + )); + } + } + let mut subject_ids = transaction_ids.iter().cloned().collect::>(); + for observation in observations { + match json_string(observation, "observationId") { + Ok(observation_id) => { + subject_ids.insert(observation_id); + } + Err(error) => failures.push(format!("{scenario}: observation {error}")), + } + } + for finding in findings { + let subject_id = match json_string(finding, "subjectId") { + Ok(subject_id) => subject_id, + Err(error) => { + failures.push(format!("{scenario}: finding {error}")); + continue; + } + }; + if !subject_ids.contains(&subject_id) + || finding["evidence"].as_array().is_none_or(Vec::is_empty) + || (finding["class"] == "confirmedFailure" + && (finding["confidence"] != "high" || finding["confidenceCeiling"] != "high")) + { + failures.push(format!( + "{scenario}: findings must cite a known subject/evidence and validate terminal confidence" + )); + } + } + + if contract.transactions + contract.observations > 0 { + if let Some(subject) = subject(expected, contract) { + if optional_json_string(subject, "phase").as_deref() != contract.phase + || subject["state"].as_str() != Some(contract.state) + || subject["classification"].as_str() != Some(contract.classification) + || subject["confidenceCeiling"].as_str() != Some(contract.confidence_ceiling) + || optional_json_string(subject, "lastSuccessfulPhase").as_deref() + != contract.last_successful_phase + { + failures.push(format!("{scenario}: primary subject outcome drifted")); + } + let next_artifact = subject["nextArtifact"]["logicalArtifactId"].as_str(); + if next_artifact != contract.next_artifact { + failures.push(format!( + "{scenario}: expected next artifact {:?}, got {next_artifact:?}", + contract.next_artifact + )); + } + } else { + failures.push(format!("{scenario}: primary subject is missing")); + } + } + + let Some(coverage) = expected["coverage"].as_array() else { + failures.push(format!("{scenario}: coverage must be an array")); + return failures; + }; + let mut coverage_pairs = Vec::new(); + for entry in coverage { + match ( + json_string(entry, "logicalArtifactId"), + json_string(entry, "state"), + ) { + (Ok(logical_id), Ok(state)) => coverage_pairs.push((logical_id, state)), + (Err(error), _) | (_, Err(error)) => { + failures.push(format!("{scenario}: coverage entry {error}")); + } + } + } + let expected_coverage_pairs = contract + .coverage + .iter() + .map(|(logical_id, state)| ((*logical_id).to_owned(), (*state).to_owned())) + .collect::>(); + if coverage_pairs != expected_coverage_pairs { + failures.push(format!( + "{scenario}: coverage projection drifted: expected {expected_coverage_pairs:?}, got {coverage_pairs:?}" + )); + } + + let handoff = &expected["correlationHandoff"]; + let empty_facts = Vec::new(); + let facts = match handoff["counterpartReadyFacts"].as_array() { + Some(facts) => facts, + None => { + failures.push(format!( + "{scenario}: counterpartReadyFacts must be an array" + )); + &empty_facts + } + }; + if handoff["issue"] != "#333" + || handoff["serverPrerequisiteIssue"] != "#330" + || handoff["performed"] != false + || handoff["timeOnlyEligible"] != false + || handoff["topologyCompatibilityEvaluated"] != false + || handoff["serverCauseClaimed"] != false + || handoff["nativeAcceptanceClaimed"] != false + || facts.len() != contract.counterpart_facts + || handoff["emittedCounterpartReadyFact"] != (contract.counterpart_facts > 0) + { + failures.push(format!("{scenario}: correlation handoff boundary drifted")); + } + for fact in facts { + if fact["keyConfidence"] != "exact" + || fact["correlationEligible"] != false + || fact["timeOnlyEligible"] != false + || fact["extractionProfileId"] != "updates-client-5.00.test-v1" + || fact["siteCode"] != "LAB" + || !fact["clientHandle"] + .as_str() + .is_some_and(|value| value.starts_with("safe:client:updates-")) + || !fact["supHostHandle"] + .as_str() + .is_some_and(|value| value.starts_with("safe:sup:lab-")) + { + failures.push(format!( + "{scenario}: counterpart fact is not exact/profile-qualified" + )); + } + if fact["evidence"]["artifactId"].as_str().is_none() + || fact["evidence"]["startLine"].as_u64().is_none() + || fact["evidence"]["endLine"].as_u64().is_none() + { + failures.push(format!( + "{scenario}: counterpart fact must cite exact physical evidence" + )); + } + } + + match string_array(expected, "prohibitedClaims") { + Ok(prohibited_claims) => { + let prohibited = prohibited_claims.join("\n"); + for required in [ + "SUP or server root cause", + "time-only cross-artifact causality", + "policy reducer dependency", + "native Windows acceptance", + ] { + if !prohibited.contains(required) { + failures.push(format!( + "{scenario}: prohibited claims must include {required:?}" + )); + } + } + } + Err(error) => failures.push(format!("{scenario}: {error}")), + } + + let profile = &expected["extractionProfile"]; + if scenario == "malformed" { + if profile["selectionState"] != "unvalidatedVersion" + || !profile["profileId"].is_null() + || !profile["sourceVersionPrefix"].is_null() + { + failures.push( + "malformed: unknown source version must not select an extraction profile" + .to_owned(), + ); + } + } else if profile["selectionState"] != "selected" + || profile["profileId"] != "updates-client-5.00.test-v1" + || profile["sourceVersionPrefix"] != "5.00.TEST." + { + failures.push(format!( + "{scenario}: selected synthetic profile identity drifted" + )); + } + if scenario == "invalid-offset" { + if let Some(transaction) = transactions.first() { + if transaction["ordering"]["crossArtifactComparable"] != false + || transaction["ordering"]["highConfidenceEligible"] != false + || transaction["ordering"]["reason"] != "invalidOffset" + { + failures.push( + "invalid-offset: invalid provenance must disable cross-artifact high confidence" + .to_owned(), + ); + } + } else { + failures.push("invalid-offset: transaction is missing".to_owned()); + } + } + if scenario == "same-minute-separate" { + let mut update_ids = BTreeSet::new(); + for transaction in transactions { + match json_string(&transaction["key"], "updateId") { + Ok(update_id) => { + update_ids.insert(update_id); + } + Err(error) => failures.push(format!("{scenario}: transaction key {error}")), + } + } + if update_ids.len() != 2 { + failures.push( + "same-minute-separate: exact update keys must remain two transactions".to_owned(), + ); + } + } + if scenario == "supplemental-conflict" { + if let (Some(transaction), Some(observation)) = (transactions.first(), observations.first()) + { + if transaction["state"] != "succeeded" + || observation["keyConfidence"] != "none" + || observation["confidenceCeiling"] != "low" + { + failures.push( + "supplemental-conflict: unkeyed CBS evidence cannot override client success" + .to_owned(), + ); + } + } else { + failures.push("supplemental-conflict: subject is missing".to_owned()); + } + } + + failures +} + +#[derive(Clone)] +struct IndexedArtifact { + manifest: Value, + physical_lines: Vec, + complete_ccm_records: Vec, +} + +fn safe_evidence_relative_path(relative_path: &str) -> bool { + let relative = Path::new(relative_path); + relative_path.starts_with("evidence/") + && !relative.is_absolute() + && !relative_path.contains('\\') + && !relative.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) +} + +fn sccm_coverage_state(state: &str) -> Option { + match state { + "captured" => Some(SccmCoverageState::Captured), + "absent" => Some(SccmCoverageState::Absent), + "accessDenied" => Some(SccmCoverageState::AccessDenied), + "capped" => Some(SccmCoverageState::Capped), + "skipped" => Some(SccmCoverageState::Skipped), + "unsupported" => Some(SccmCoverageState::Unsupported), + "parseFailed" => Some(SccmCoverageState::ParseFailed), + _ => None, + } +} + +fn evidence_index( + scenario_dir: &Path, + manifest: &Value, +) -> (BTreeMap, Vec) { + let mut index = BTreeMap::new(); + let mut failures = Vec::new(); + let Some(artifacts) = manifest["artifacts"].as_array() else { + return ( + index, + vec!["manifest artifacts must be an array".to_owned()], + ); + }; + + for artifact in artifacts { + let Some(artifact_id) = artifact["artifactId"].as_str() else { + failures.push("manifest artifactId must be a string".to_owned()); + continue; + }; + let mut physical_lines = Vec::new(); + let mut complete_ccm_records = Vec::new(); + if let Some(relative_path) = artifact["relativePath"].as_str() { + if safe_evidence_relative_path(relative_path) { + let path = scenario_dir.join(relative_path); + if let Ok(contents) = std::fs::read_to_string(&path) { + physical_lines = contents.lines().map(str::to_owned).collect(); + if artifact["captureState"] == "captured" + && artifact["rotation"]["fragmentComplete"] == true + && artifact["kind"] == "ccmLog" + { + let Some(display_name) = artifact["originalBasename"].as_str() else { + failures + .push(format!("{artifact_id}: originalBasename must be a string")); + continue; + }; + let Some(coverage) = artifact["captureState"] + .as_str() + .and_then(sccm_coverage_state) + else { + failures.push(format!( + "{artifact_id}: captureState cannot build SCCM evidence" + )); + continue; + }; + complete_ccm_records = normalize_ccm_artifact( + SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: display_name.to_owned(), + original_path: artifact["sanitizedSourcePath"] + .as_str() + .map(str::to_owned), + host: None, + role: SccmRole::Client, + configmgr_version: artifact["sourceVersion"] + .as_str() + .map(str::to_owned), + collected_at_utc: artifact["capturedUtc"] + .as_str() + .map(str::to_owned), + rotation: SccmRotation::Current, + coverage, + encoding: artifact["encoding"].as_str().map(str::to_owned), + }, + &contents, + ); + } + } + } + } + + if index + .insert( + artifact_id.to_owned(), + IndexedArtifact { + manifest: artifact.clone(), + physical_lines, + complete_ccm_records, + }, + ) + .is_some() + { + failures.push(format!("{artifact_id}: artifact ID is duplicated")); + } + } + + (index, failures) +} + +fn citation_triples( + value: &Value, + label: &str, + failures: &mut Vec, +) -> Vec<(String, u64, u64)> { + let Some(citations) = value.as_array() else { + failures.push(format!("{label}: evidence citations must be an array")); + return Vec::new(); + }; + citations + .iter() + .filter_map(|citation| { + let Some(artifact_id) = citation["artifactId"].as_str() else { + failures.push(format!("{label}: citation artifactId must be a string")); + return None; + }; + let Some(start_line) = citation["startLine"].as_u64() else { + failures.push(format!("{label}: citation startLine must be an integer")); + return None; + }; + let Some(end_line) = citation["endLine"].as_u64() else { + failures.push(format!("{label}: citation endLine must be an integer")); + return None; + }; + Some((artifact_id.to_owned(), start_line, end_line)) + }) + .collect() +} + +fn evidence_refs_cite_unique_records(references: &[(String, u64, u64)]) -> bool { + let mut cited_records = BTreeSet::new(); + references + .iter() + .all(|(artifact_id, start_line, end_line)| { + (*start_line..=*end_line).all(|line| cited_records.insert((artifact_id.clone(), line))) + }) +} + +fn citation_failures( + label: &str, + citations: &Value, + index: &BTreeMap, +) -> Vec { + let mut failures = Vec::new(); + let mut in_bounds = Vec::new(); + for (artifact_id, start_line, end_line) in citation_triples(citations, label, &mut failures) { + let Some(artifact) = index.get(&artifact_id) else { + failures.push(format!( + "{label}: same-scenario citation references unknown artifact {artifact_id}" + )); + continue; + }; + let line_count = artifact.physical_lines.len() as u64; + if line_count == 0 { + failures.push(format!( + "{label}: same-scenario citation references nonphysical artifact {artifact_id}" + )); + } else if start_line == 0 || end_line < start_line || end_line > line_count { + failures.push(format!( + "{label}: same-scenario citation {artifact_id}:{start_line}-{end_line} exceeds {line_count} lines" + )); + } else { + in_bounds.push((artifact_id, start_line, end_line)); + } + } + if !evidence_refs_cite_unique_records(&in_bounds) { + failures.push(format!( + "{label}: evidence ranges overlap and double-count a cited record" + )); + } + failures +} + +fn cited_complete_records<'a>( + citations: &Value, + index: &'a BTreeMap, +) -> Vec<&'a SccmEvidence> { + let mut ignored_failures = Vec::new(); + citation_triples(citations, "cited records", &mut ignored_failures) + .into_iter() + .flat_map(|(artifact_id, start_line, end_line)| { + index + .get(&artifact_id) + .into_iter() + .flat_map(move |artifact| { + artifact.complete_ccm_records.iter().filter(move |record| { + record.reference.line_start.is_some_and(|line| { + u64::from(line) >= start_line + && record + .reference + .line_end + .is_some_and(|end| u64::from(end) <= end_line) + }) + }) + }) + }) + .collect() +} + +fn exact_message_field<'a>(message: &'a str, field: &str) -> Option<&'a str> { + message.split_ascii_whitespace().find_map(|token| { + let (name, value) = token.split_once('=')?; + (name == field).then(|| value.trim_matches(['{', '}'])) + }) +} + +fn record_matches_transaction_key(record: &SccmEvidence, key: &Value) -> bool { + [ + ("updateId", "UpdateId"), + ("ciId", "CiId"), + ("contentId", "ContentId"), + ("updateJobId", "UpdateJobId"), + ("clientHandle", "ClientHandle"), + ("siteCode", "SiteCode"), + ] + .iter() + .all(|(json_field, message_field)| { + key[*json_field] + .as_str() + .is_some_and(|value| exact_message_field(&record.message, message_field) == Some(value)) + }) && key["supHostHandle"].as_str().is_none_or(|sup_handle| { + exact_message_field(&record.message, "SupHostHandle") == Some(sup_handle) + }) +} + +fn message_contains_tokens(message: &str, expected: &[&str]) -> bool { + let tokens = message.split_ascii_whitespace().collect::>(); + tokens + .windows(expected.len()) + .any(|window| window == expected) +} + +fn phase_source_is_compatible(phase: &str, basename: &str) -> bool { + match phase { + "scan" => basename == "ScanAgent.log", + "evaluate" => matches!(basename, "ScanAgent.log" | "WUAHandler.log"), + "locateSup" => basename == "LocationServices.log", + "download" => matches!( + basename, + "DataTransferService.log" | "ContentTransferManager.log" | "UpdatesDeployment.log" + ), + "maintenanceWindow" => matches!( + basename, + "ServiceWindowManager.log" | "UpdatesHandler.log" | "UpdatesDeployment.log" + ), + "install" => matches!(basename, "UpdatesHandler.log" | "UpdatesDeployment.log"), + "reboot" => matches!(basename, "RebootCoordinator.log" | "UpdatesDeployment.log"), + "report" => matches!(basename, "StateMessage.log" | "UpdatesHandler.log"), + _ => false, + } +} + +fn phase_token(phase: &str) -> Option<&'static str> { + match phase { + "scan" => Some("Scan"), + "evaluate" => Some("Evaluate"), + "locateSup" => Some("LocateSup"), + "download" => Some("Download"), + "maintenanceWindow" => Some("MaintenanceWindow"), + "install" => Some("Install"), + "reboot" => Some("Reboot"), + "report" => Some("Report"), + _ => None, + } +} + +fn record_proves_phase_outcome( + record: &SccmEvidence, + artifact: &IndexedArtifact, + transaction: &Value, +) -> bool { + let Some(phase) = transaction["phase"].as_str() else { + return false; + }; + let Some(phase_token) = phase_token(phase) else { + return false; + }; + let Some(basename) = artifact.manifest["originalBasename"].as_str() else { + return false; + }; + if !phase_source_is_compatible(phase, basename) + || !record_matches_transaction_key(record, &transaction["key"]) + { + return false; + } + + match ( + transaction["classification"].as_str(), + transaction["state"].as_str(), + ) { + (Some("success"), Some("succeeded")) => { + message_contains_tokens(&record.message, &[phase_token, "succeeded"]) + } + (Some("confirmedFailure"), Some("failed")) => { + message_contains_tokens(&record.message, &[phase_token, "terminal", "failure"]) + } + (Some("blockedOrDeferred"), Some("blockedOrDeferred")) => match phase { + "maintenanceWindow" => { + message_contains_tokens(&record.message, &["MaintenanceWindow", "deferred"]) + } + "reboot" => message_contains_tokens(&record.message, &["Reboot", "pending"]), + _ => false, + }, + _ => false, + } +} + +fn record_proves_successful_phase( + record: &SccmEvidence, + artifact: &IndexedArtifact, + key: &Value, + phase: &str, +) -> bool { + let Some(basename) = artifact.manifest["originalBasename"].as_str() else { + return false; + }; + if !phase_source_is_compatible(phase, basename) || !record_matches_transaction_key(record, key) + { + return false; + } + + match phase { + "scan" => message_contains_tokens(&record.message, &["Scan", "succeeded"]), + "evaluate" => message_contains_tokens(&record.message, &["Evaluate", "applicable"]), + "locateSup" => message_contains_tokens(&record.message, &["LocateSup", "selected"]), + "download" => message_contains_tokens(&record.message, &["Download", "succeeded"]), + "maintenanceWindow" => { + message_contains_tokens(&record.message, &["MaintenanceWindow", "open"]) + } + "install" => message_contains_tokens(&record.message, &["Install", "succeeded"]), + "reboot" => message_contains_tokens(&record.message, &["Reboot", "complete"]), + "report" => message_contains_tokens(&record.message, &["Report", "succeeded"]), + _ => false, + } +} + +fn expected_transaction_gaps(scenario: &str) -> &'static [&'static str] { + match scenario { + "access-denied" => &["client-updates"], + "capped" => &["client-content"], + "incomplete" | "maintenance-window" => &["client-maintenance-window"], + "invalid-offset" => &["client-updates"], + "no-sup" => &["client-location-services-shared"], + "reboot-pending" => &["client-reboot"], + _ => &[], + } +} + +fn transaction_binding_failures( + scenario: &str, + expected: &Value, + index: &BTreeMap, +) -> Vec { + let mut failures = Vec::new(); + let Some(transactions) = expected["transactions"].as_array() else { + return vec![format!("{scenario}: transactions must be an array")]; + }; + let profile_id = expected["extractionProfile"]["profileId"].as_str(); + let source_prefix = expected["extractionProfile"]["sourceVersionPrefix"].as_str(); + let key_fields = [ + ("updateId", "UpdateId"), + ("ciId", "CiId"), + ("contentId", "ContentId"), + ("updateJobId", "UpdateJobId"), + ("clientHandle", "ClientHandle"), + ("siteCode", "SiteCode"), + ]; + + for transaction in transactions { + let transaction_id = transaction["transactionId"] + .as_str() + .unwrap_or(""); + failures.extend(citation_failures( + transaction_id, + &transaction["evidence"], + index, + )); + let cited_records = cited_complete_records(&transaction["evidence"], index); + let compatible_records = cited_records + .iter() + .copied() + .filter(|record| { + index + .get(&record.reference.artifact_id) + .and_then(|artifact| artifact.manifest["sourceVersion"].as_str()) + .zip(source_prefix) + .is_some_and(|(version, prefix)| version.starts_with(prefix)) + }) + .collect::>(); + let key = &transaction["key"]; + for (json_field, message_field) in key_fields { + let Some(value) = key[json_field].as_str() else { + failures.push(format!( + "{scenario}: exact transaction key {transaction_id} has missing/non-string {json_field}" + )); + continue; + }; + if !compatible_records + .iter() + .any(|record| exact_message_field(&record.message, message_field) == Some(value)) + { + failures.push(format!( + "{scenario}: exact transaction key {transaction_id} {json_field}={value:?} is not bound to cited profile-compatible CCM evidence" + )); + } + } + if !compatible_records + .iter() + .any(|record| record_matches_transaction_key(record, key)) + { + failures.push(format!( + "{scenario}: complete exact key tuple for {transaction_id} does not co-occur in one cited profile-compatible CCM record" + )); + } + if key["confidence"] != "exact" + || key["extractionProfileId"].as_str() != profile_id + || key["siteCode"] != "LAB" + || key["updateId"].as_str().is_none_or(|update_id| { + transaction["transactionId"] != format!("updates:update:{update_id}") + }) + { + failures.push(format!( + "{scenario}: exact transaction key metadata drifted for {transaction_id}" + )); + } + + let sup_handle_present = key + .as_object() + .is_some_and(|object| object.contains_key("supHostHandle")); + match key["supHostHandle"].as_str() { + Some(sup_handle) => { + let exact_location = compatible_records.iter().any(|record| { + index + .get(&record.reference.artifact_id) + .is_some_and(|artifact| { + artifact.manifest["designOnlyCatalog"]["entryId"] + == "client-location-services-shared" + && record.message.contains("LocateSup selected") + && exact_message_field(&record.message, "SupHostHandle") + == Some(sup_handle) + }) + }); + if !exact_location { + failures.push(format!( + "{scenario}: SUP handle without LocateSup evidence is not exact" + )); + } + } + None if sup_handle_present && key["supHostHandle"].is_null() => {} + None => failures.push(format!( + "{scenario}: unavailable supHostHandle must be represented as null" + )), + } + + let requires_phase_outcome = matches!( + ( + transaction["classification"].as_str(), + transaction["state"].as_str() + ), + (Some("success"), Some("succeeded")) + | (Some("confirmedFailure"), Some("failed")) + | (Some("blockedOrDeferred"), Some("blockedOrDeferred")) + ); + if requires_phase_outcome + && !compatible_records.iter().any(|record| { + index + .get(&record.reference.artifact_id) + .is_some_and(|artifact| { + record_proves_phase_outcome(record, artifact, transaction) + }) + }) + { + failures.push(format!( + "{scenario}: phase/state evidence is missing for {transaction_id}; phase outcome evidence is missing" + )); + } + + if let Some(last_successful_phase) = transaction["lastSuccessfulPhase"].as_str() { + let last_success_is_proven = compatible_records.iter().any(|record| { + index + .get(&record.reference.artifact_id) + .is_some_and(|artifact| { + record_proves_successful_phase(record, artifact, key, last_successful_phase) + }) + }); + if !last_success_is_proven { + failures.push(format!( + "{scenario}: last successful phase evidence is missing for {transaction_id}: {last_successful_phase}" + )); + } + } + + match string_array(transaction, "coverageGapArtifactIds") { + Ok(actual_gaps) => { + if actual_gaps != expected_transaction_gaps(scenario) { + failures.push(format!( + "{scenario}: coverage gaps drifted for {transaction_id}: {actual_gaps:?}" + )); + } + } + Err(error) => failures.push(format!("{scenario}: {transaction_id} {error}")), + } + } + failures +} + +fn finding_binding_failures(scenario: &str, expected: &Value) -> Vec { + let mut failures = Vec::new(); + let mut subjects = BTreeMap::new(); + for transaction in expected["transactions"].as_array().into_iter().flatten() { + if let Some(id) = transaction["transactionId"].as_str() { + subjects.insert(id, transaction); + } + } + for observation in expected["sourceLocalObservations"] + .as_array() + .into_iter() + .flatten() + { + if let Some(id) = observation["observationId"].as_str() { + subjects.insert(id, observation); + } + } + + for finding in expected["findings"].as_array().into_iter().flatten() { + let Some(subject_id) = finding["subjectId"].as_str() else { + failures.push(format!("{scenario}: finding subjectId must be a string")); + continue; + }; + let Some(subject) = subjects.get(subject_id) else { + failures.push(format!( + "{scenario}: finding/subject binding references unknown {subject_id}" + )); + continue; + }; + if finding["class"] != subject["classification"] + || finding["phase"] != subject["phase"] + || finding["lastSuccessfulPhase"] != subject["lastSuccessfulPhase"] + || finding["confidence"] != subject["confidence"] + || finding["confidenceCeiling"] != subject["confidenceCeiling"] + || finding["nextArtifact"] != subject["nextArtifact"] + || finding["evidence"] != subject["evidence"] + { + failures.push(format!( + "{scenario}: finding/subject binding drifted for {subject_id}" + )); + } + } + failures +} + +fn conservative_outcome_failures(scenario: &str, expected: &Value) -> Vec { + let mut failures = Vec::new(); + if scenario == "supplemental-conflict" { + let observation = &expected["sourceLocalObservations"][0]; + let finding = &expected["findings"][0]; + let transaction = &expected["transactions"][0]; + if observation["confidence"] != "low" + || observation["confidenceCeiling"] != "low" + || observation["correlationEligible"] != false + || finding["confidence"] != "low" + || finding["confidenceCeiling"] != "low" + || transaction["phase"] != "install" + || transaction["state"] != "succeeded" + || transaction["classification"] != "success" + { + failures.push( + "supplemental-conflict: conservative confidence/outcome boundary drifted" + .to_owned(), + ); + } + } + if scenario == "invalid-offset" { + let transaction = &expected["transactions"][0]; + let finding = &expected["findings"][0]; + if transaction["confidence"] != "low" + || transaction["confidenceCeiling"] != "low" + || finding["confidence"] != "low" + || finding["confidenceCeiling"] != "low" + || transaction["ordering"]["crossArtifactComparable"] != false + || transaction["ordering"]["highConfidenceEligible"] != false + || transaction["ordering"]["reason"] != "invalidOffset" + { + failures.push( + "invalid-offset: conservative confidence/ordering boundary drifted".to_owned(), + ); + } + } + if scenario == "same-minute-separate" { + let transactions = &expected["transactions"]; + let first = &transactions[0]; + let second = &transactions[1]; + let separate = first["transactionId"] + == "updates:update:32300000-0000-0000-0000-000000000015" + && first["key"]["updateId"] == "32300000-0000-0000-0000-000000000015" + && first["phase"] == "report" + && first["state"] == "succeeded" + && first["classification"] == "success" + && first["evidence"] + == serde_json::json!([{ + "artifactId": "updates-same-minute-separate-01-updates", + "startLine": 1, + "endLine": 1 + }]) + && second["transactionId"] == "updates:update:32300000-0000-0000-0000-000000000016" + && second["key"]["updateId"] == "32300000-0000-0000-0000-000000000016" + && second["phase"] == "install" + && second["state"] == "failed" + && second["classification"] == "confirmedFailure" + && second["evidence"] + == serde_json::json!([{ + "artifactId": "updates-same-minute-separate-01-updates", + "startLine": 2, + "endLine": 2 + }]); + if !separate { + failures + .push("same-minute-separate: same-minute transaction outcomes drifted".to_owned()); + } + } + failures +} + +fn experimental_profile_causality_failures(expected: &Value) -> Vec { + let mut failures = Vec::new(); + let profile_id = expected["extractionProfile"]["profileId"].as_str(); + for transaction in expected["transactions"].as_array().into_iter().flatten() { + let uses_experimental_profile = profile_id == Some(SCCM_EXPERIMENTAL_KEY_PROFILE_ID) + || transaction["key"]["extractionProfileId"] == SCCM_EXPERIMENTAL_KEY_PROFILE_ID; + if uses_experimental_profile + && (transaction["key"]["confidence"] != "low" + || transaction["confidence"] != "low" + || transaction["confidenceCeiling"] != "low" + || transaction["classification"] == "confirmedFailure") + { + failures.push( + "experimental Low key profile cannot establish causal transaction confidence" + .to_owned(), + ); + } + } + for fact in expected["correlationHandoff"]["counterpartReadyFacts"] + .as_array() + .into_iter() + .flatten() + { + let uses_experimental_profile = profile_id == Some(SCCM_EXPERIMENTAL_KEY_PROFILE_ID) + || fact["extractionProfileId"] == SCCM_EXPERIMENTAL_KEY_PROFILE_ID; + if uses_experimental_profile + && (fact["keyConfidence"] != "low" || fact["correlationEligible"] != false) + { + failures.push( + "experimental Low key profile cannot emit a correlation-eligible fact".to_owned(), + ); + } + } + failures +} + +fn expected_catalog_group(basename: &str) -> Option<&'static str> { + match basename { + "ScanAgent.log" + | "ScanAgent.lo_" + | "WUAHandler.log" + | "UpdatesDeployment.log" + | "UpdatesHandler.log" + | "UpdatesStore.log" => Some("client-updates"), + "LocationServices.log" => Some("client-location-services-shared"), + "DataTransferService.log" | "ContentTransferManager.log" => Some("client-content"), + "ServiceWindowManager.log" => Some("client-maintenance-window"), + "RebootCoordinator.log" => Some("client-reboot"), + "StateMessage.log" => Some("client-policy-state"), + "CBS.log" | "ReportingEvents.log" => Some("client-windows-update-supplemental"), + _ => None, + } +} + +fn expected_rotation_kind(basename: &str) -> &'static str { + if basename.ends_with(".lo_") { + "lo" + } else { + "current" + } +} + +fn privacy_safe_opaque_segment(value: &str) -> bool { + (1..=96).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) +} + +fn privacy_safe_path_fingerprint(artifact_id: &str, fingerprint: &str) -> bool { + privacy_safe_opaque_segment(artifact_id) + && fingerprint + .strip_prefix("synthetic:") + .is_some_and(|opaque| opaque == artifact_id && privacy_safe_opaque_segment(opaque)) +} + +fn privacy_safe_sanitized_source_path(path: &str, basename: &str) -> bool { + let prefix = match basename { + "CBS.log" => "SYNTHETIC://root-a/Windows/Logs/CBS/", + "ReportingEvents.log" => "SYNTHETIC://root-a/WindowsUpdate/", + _ => "SYNTHETIC://root-a/CCM/Logs/", + }; + path.strip_prefix(prefix) == Some(basename) +} + +fn manifest_artifact_identity_failures(artifact: &Value) -> Vec { + let mut failures = Vec::new(); + let artifact_id = artifact["artifactId"].as_str().unwrap_or(""); + if artifact["role"] != "client" { + failures.push(format!("{artifact_id}: artifact role must remain client")); + } + + match &artifact["pathFingerprint"] { + Value::Null => {} + Value::String(fingerprint) if privacy_safe_path_fingerprint(artifact_id, fingerprint) => {} + _ => { + failures.push(format!( + "{artifact_id}: pathFingerprint must use the privacy-safe pathFingerprint grammar and bind exactly to artifactId" + )); + } + } + + let basename = artifact["originalBasename"].as_str(); + match (&artifact["sanitizedSourcePath"], basename) { + (Value::Null, _) => {} + (Value::String(source_path), Some(basename)) + if privacy_safe_sanitized_source_path(source_path, basename) => {} + _ => { + failures.push(format!( + "{artifact_id}: sanitizedSourcePath must use a privacy-safe sanitizedSourcePath and bind exactly to originalBasename" + )); + } + } + let entry_id = artifact["designOnlyCatalog"]["entryId"].as_str(); + let expected_group = basename.and_then(expected_catalog_group); + if expected_group.is_none() || entry_id != expected_group { + failures.push(format!( + "{artifact_id}: catalog entry/logical group is incompatible with basename {basename:?}" + )); + } + if expected_group.is_none_or(|group| { + artifact["designOnlyCatalog"]["groupMemberships"] != serde_json::json!([group]) + }) { + failures.push(format!( + "{artifact_id}: group memberships must contain one canonical logical group" + )); + } + + let rotation_kind = artifact["rotation"]["kind"].as_str(); + if basename.is_none_or(|basename| rotation_kind != Some(expected_rotation_kind(basename))) { + failures.push(format!( + "{artifact_id}: rotation kind is incompatible with the original basename" + )); + } + + if let (Some(relative_path), Some(entry_id), Some(rotation_kind), Some(basename)) = ( + artifact["relativePath"].as_str(), + entry_id, + rotation_kind, + basename, + ) { + let expected_path = format!("evidence/{entry_id}/{rotation_kind}/{basename}"); + if relative_path != expected_path { + failures.push(format!( + "{artifact_id}: relativePath is incompatible with catalog group/rotation/basename; expected {expected_path}" + )); + } + } + + failures +} + +fn manifest_artifact_kind_failures(artifact: &Value) -> Vec { + let artifact_id = artifact["artifactId"].as_str().unwrap_or(""); + let group = artifact["designOnlyCatalog"]["entryId"].as_str(); + let basename = artifact["originalBasename"].as_str(); + let expected_kind = match basename { + Some("CBS.log") => "cbsLog", + Some("ReportingEvents.log") => "supplementalLog", + _ => "ccmLog", + }; + let mut failures = Vec::new(); + if basename == Some("ReportingEvents.log") && artifact["kind"] != "supplementalLog" { + failures.push(format!( + "{artifact_id}: ReportingEvents.log must use supplementalLog" + )); + } else if group.is_none() + || (group != Some("client-windows-update-supplemental") && artifact["kind"] != "ccmLog") + || artifact["kind"] != expected_kind + { + failures.push(format!( + "{artifact_id}: artifact kind {:?} is incompatible with group/basename", + artifact["kind"] + )); + } + if matches!(expected_kind, "cbsLog" | "supplementalLog") && !artifact["sourceVersion"].is_null() + { + failures.push(format!( + "{artifact_id}: {expected_kind} sourceVersion must be null" + )); + } + failures +} + +fn coverage_state_for_artifact(artifact: &Value) -> Option { + let state = artifact["captureState"].as_str()?; + if state == "captured" && artifact["rotation"]["fragmentComplete"] == false { + Some("partial".to_owned()) + } else { + Some(state.to_owned()) + } +} + +fn coverage_projection(manifest: &Value) -> (Value, Vec) { + let mut states_by_family = BTreeMap::>::new(); + let mut failures = Vec::new(); + let Some(artifacts) = manifest["artifacts"].as_array() else { + return ( + Value::Array(Vec::new()), + vec!["coverage projection requires manifest artifacts".to_owned()], + ); + }; + for artifact in artifacts { + let Some(state) = coverage_state_for_artifact(artifact) else { + failures.push("coverage projection found invalid captureState".to_owned()); + continue; + }; + let Some(groups) = artifact["designOnlyCatalog"]["groupMemberships"].as_array() else { + failures.push("coverage projection found invalid groupMemberships".to_owned()); + continue; + }; + for group in groups { + let Some(group) = group.as_str() else { + failures.push("coverage projection found non-string family".to_owned()); + continue; + }; + states_by_family + .entry(group.to_owned()) + .or_default() + .insert(state.clone()); + } + } + + let mut projection = Vec::new(); + for (family, mut states) in states_by_family { + if states.len() > 1 { + states.remove("captured"); + } + if states.len() != 1 { + failures.push(format!( + "coverage projection has conflicting states for {family}: {states:?}" + )); + continue; + } + let state = states + .into_iter() + .next() + .expect("one projected coverage state remains"); + projection.push(serde_json::json!({ + "logicalArtifactId": family, + "state": state + })); + } + (Value::Array(projection), failures) +} + +fn artifact_provenance_projection(manifest: &Value) -> (Value, Vec) { + let mut projection = Vec::new(); + let mut failures = Vec::new(); + let Some(artifacts) = manifest["artifacts"].as_array() else { + return ( + Value::Array(projection), + vec!["artifact provenance requires manifest artifacts".to_owned()], + ); + }; + for artifact in artifacts { + let Some(artifact_id) = artifact["artifactId"].as_str() else { + failures.push("artifact provenance found invalid artifactId".to_owned()); + continue; + }; + let Some(capture_state) = artifact["captureState"].as_str() else { + failures.push(format!( + "{artifact_id}: artifact provenance found invalid captureState" + )); + continue; + }; + let physical = matches!(capture_state, "captured" | "capped"); + let encoding = if physical { + artifact["encoding"].clone() + } else { + Value::Null + }; + let byte_limit = if physical { + artifact["collectionLimit"]["byteLimit"].clone() + } else { + Value::Null + }; + let limit_applied = if physical { + artifact["collectionLimit"]["limitApplied"].clone() + } else { + Value::Bool(false) + }; + projection.push(serde_json::json!({ + "artifactId": artifact_id, + "captureState": capture_state, + "encoding": encoding, + "byteLimit": byte_limit, + "limitApplied": limit_applied + })); + } + (Value::Array(projection), failures) +} + +fn profile_binding_failures(manifest: &Value, expected: &Value, scenario: &str) -> Vec { + let mut failures = Vec::new(); + let profile = &expected["extractionProfile"]; + let Some(validated_family_values) = profile["validatedArtifactFamilies"].as_array() else { + return vec![format!( + "{scenario}: validatedArtifactFamilies must be an array" + )]; + }; + let mut validated_families = Vec::new(); + for value in validated_family_values { + let Some(value) = value.as_str() else { + failures.push(format!( + "{scenario}: validatedArtifactFamilies values must be strings" + )); + continue; + }; + validated_families.push(value.to_owned()); + } + if profile["selectionState"] == "unvalidatedVersion" { + if !validated_families.is_empty() { + failures.push(format!( + "{scenario}: source profile/version cannot validate families for an unknown profile" + )); + } + return failures; + } + let Some(prefix) = profile["sourceVersionPrefix"].as_str() else { + return vec![format!( + "{scenario}: source profile/version prefix must be a string" + )]; + }; + let Some(artifacts) = manifest["artifacts"].as_array() else { + return vec![format!( + "{scenario}: source profile/version requires manifest artifacts" + )]; + }; + let derived = artifacts + .iter() + .filter(|artifact| { + artifact["captureState"] == "captured" + && artifact["rotation"]["fragmentComplete"] == true + && artifact["kind"] == "ccmLog" + && artifact["sourceVersion"] + .as_str() + .is_some_and(|version| version.starts_with(prefix)) + }) + .filter_map(|artifact| artifact["designOnlyCatalog"]["entryId"].as_str()) + .map(str::to_owned) + .collect::>() + .into_iter() + .collect::>(); + if validated_families != derived { + failures.push(format!( + "{scenario}: source profile/version family projection drifted: expected {derived:?}, got {validated_families:?}" + )); + } + failures +} + +fn manifest_expected_binding_failures( + scenario_dir: &Path, + manifest: &Value, + expected: &Value, + scenario: &str, +) -> Vec { + let mut failures = manifest_identity_failures(manifest, scenario); + let Some(artifacts) = manifest["artifacts"].as_array() else { + failures.push(format!("{scenario}: manifest artifacts must be an array")); + return failures; + }; + let artifact_ids = artifacts + .iter() + .filter_map(|artifact| artifact["artifactId"].as_str()) + .collect::>(); + let mut sorted_ids = artifact_ids.clone(); + sorted_ids.sort_unstable(); + if artifact_ids != sorted_ids + || artifact_ids.iter().collect::>().len() != artifact_ids.len() + { + failures.push(format!( + "{scenario}: manifest artifact IDs must be unique and sorted" + )); + } + let mut relative_path_owners = BTreeMap::new(); + let mut path_fingerprint_owners = BTreeMap::new(); + for artifact in artifacts { + let artifact_id = artifact["artifactId"].as_str().unwrap_or(""); + if let Some(relative_path) = artifact["relativePath"].as_str() { + if let Some(first_id) = + relative_path_owners.insert(relative_path.to_owned(), artifact_id.to_owned()) + { + failures.push(format!( + "{scenario}: duplicate physical alias relativePath {relative_path:?} for {first_id} and {artifact_id}" + )); + } + } + if let Some(path_fingerprint) = artifact["pathFingerprint"].as_str() { + if let Some(first_id) = + path_fingerprint_owners.insert(path_fingerprint.to_owned(), artifact_id.to_owned()) + { + failures.push(format!( + "{scenario}: duplicate physical alias pathFingerprint {path_fingerprint:?} for {first_id} and {artifact_id}" + )); + } + } + failures.extend( + manifest_artifact_failures(scenario_dir, artifact) + .into_iter() + .map(|failure| format!("{scenario}: {failure}")), + ); + failures.extend( + manifest_artifact_identity_failures(artifact) + .into_iter() + .map(|failure| format!("{scenario}: {failure}")), + ); + failures.extend( + manifest_artifact_kind_failures(artifact) + .into_iter() + .map(|failure| format!("{scenario}: {failure}")), + ); + } + + let (derived_coverage, coverage_failures) = coverage_projection(manifest); + failures.extend( + coverage_failures + .into_iter() + .map(|failure| format!("{scenario}: {failure}")), + ); + if expected["coverage"] != derived_coverage { + failures.push(format!( + "{scenario}: coverage projection does not match the manifest" + )); + } + + let (derived_provenance, provenance_failures) = artifact_provenance_projection(manifest); + failures.extend( + provenance_failures + .into_iter() + .map(|failure| format!("{scenario}: {failure}")), + ); + if expected["artifactProvenance"] != derived_provenance { + failures.push(format!( + "{scenario}: artifact provenance does not match the manifest one-to-one" + )); + } + failures.extend(profile_binding_failures(manifest, expected, scenario)); + + let (index, index_failures) = evidence_index(scenario_dir, manifest); + failures.extend( + index_failures + .into_iter() + .map(|failure| format!("{scenario}: {failure}")), + ); + failures.extend(transaction_binding_failures(scenario, expected, &index)); + for observation in expected["sourceLocalObservations"] + .as_array() + .into_iter() + .flatten() + { + failures.extend(citation_failures( + observation["observationId"] + .as_str() + .unwrap_or(""), + &observation["evidence"], + &index, + )); + } + for finding in expected["findings"].as_array().into_iter().flatten() { + failures.extend(citation_failures( + finding["findingId"] + .as_str() + .unwrap_or(""), + &finding["evidence"], + &index, + )); + } + failures.extend(finding_binding_failures(scenario, expected)); + failures.extend(conservative_outcome_failures(scenario, expected)); + failures.extend(experimental_profile_causality_failures(expected)); + failures +} + +fn counterpart_source_failures( + scenario_dir: &Path, + manifest: &Value, + expected: &Value, +) -> Vec { + let mut failures = Vec::new(); + let Some(facts) = expected["correlationHandoff"]["counterpartReadyFacts"].as_array() else { + return vec!["counterpartReadyFacts must be an array".to_owned()]; + }; + let Some(transactions) = expected["transactions"].as_array() else { + return vec!["transactions must be an array".to_owned()]; + }; + let (index, _) = evidence_index(scenario_dir, manifest); + let source_prefix = expected["extractionProfile"]["sourceVersionPrefix"].as_str(); + let fact_fields = [ + ("updateId", "UpdateId"), + ("ciId", "CiId"), + ("contentId", "ContentId"), + ("updateJobId", "UpdateJobId"), + ("clientHandle", "ClientHandle"), + ("siteCode", "SiteCode"), + ("supHostHandle", "SupHostHandle"), + ]; + + for fact in facts { + if expected["correlationHandoff"]["topologyCompatibilityEvaluated"].as_bool() != Some(true) + && (fact + .as_object() + .is_some_and(|fact| fact.contains_key("topologyCompatible")) + || fact["correlationEligible"] != false) + { + failures.push( + "counterpart fact cannot claim unevaluated topology compatibility or correlation eligibility" + .to_owned(), + ); + } + if fact["topologyCompatible"] == false && fact["correlationEligible"] != false { + failures.push( + "counterpart topology mismatch cannot remain correlation eligible".to_owned(), + ); + } + if fact["keyConfidence"] != "exact" + || fact["correlationEligible"] != false + || fact["timeOnlyEligible"] != false + || fact["extractionProfileId"] != expected["extractionProfile"]["profileId"] + || fact["phase"] != "locateSup" + { + failures.push("counterpart fact exact/correlation metadata drifted".to_owned()); + } + + let mut exact_values = Vec::new(); + for (json_field, message_field) in fact_fields { + let Some(value) = fact[json_field].as_str() else { + failures.push(format!( + "exact counterpart key field {json_field} must be a string" + )); + continue; + }; + exact_values.push((json_field, message_field, value)); + } + let matching_transaction = fact["updateId"].as_str().and_then(|update_id| { + transactions + .iter() + .find(|transaction| transaction["key"]["updateId"] == update_id) + }); + if matching_transaction.is_none_or(|transaction| { + exact_values + .iter() + .any(|(json_field, _, value)| transaction["key"][*json_field] != **value) + }) { + failures.push( + "exact counterpart key does not match one exact client transaction".to_owned(), + ); + } + + let citations = Value::Array(vec![fact["evidence"].clone()]); + failures.extend(citation_failures("counterpart fact", &citations, &index)); + let Some(artifact_id) = fact["evidence"]["artifactId"].as_str() else { + failures.push( + "counterpart fact needs explicit LocationServices LocateSup evidence".to_owned(), + ); + continue; + }; + let Some(artifact) = index.get(artifact_id) else { + failures.push(format!( + "{artifact_id}: counterpart fact needs explicit LocationServices LocateSup evidence" + )); + continue; + }; + let is_complete_location_source = artifact.manifest["designOnlyCatalog"]["entryId"] + == "client-location-services-shared" + && artifact.manifest["kind"] == "ccmLog" + && artifact.manifest["captureState"] == "captured" + && artifact.manifest["rotation"]["fragmentComplete"] == true + && artifact.manifest["originalBasename"] == "LocationServices.log" + && artifact.manifest["sourceVersion"] + .as_str() + .zip(source_prefix) + .is_some_and(|(version, prefix)| version.starts_with(prefix)); + let cited_records = cited_complete_records(&citations, &index); + if !is_complete_location_source || cited_records.len() != 1 { + failures.push(format!( + "{artifact_id}: counterpart fact needs explicit LocationServices LocateSup evidence" + )); + continue; + } + let record = cited_records[0]; + if !record.message.contains("LocateSup selected") + || exact_values.iter().any(|(_, message_field, value)| { + exact_message_field(&record.message, message_field) != Some(*value) + }) + { + failures.push(format!( + "{artifact_id}: exact counterpart key is not bound to the cited LocateSup record" + )); + } + + let usable_timestamp = match ( + &record.timestamp.ordering_state, + record.timestamp.utc_millis, + record.timestamp.offset_minutes, + ) { + (SccmTimeOrderingState::NormalizedUtc, Some(utc_millis), Some(offset_minutes)) => { + DateTime::::from_timestamp_millis(utc_millis).map(|timestamp| { + serde_json::json!({ + "normalizedUtc": timestamp.to_rfc3339_opts(SecondsFormat::Millis, true), + "utcMillis": utc_millis, + "offsetMinutes": offset_minutes, + "orderingState": "normalizedUtc" + }) + }) + } + _ => None, + }; + if usable_timestamp.as_ref() != Some(&fact["timestampProvenance"]) { + failures.push(format!( + "{artifact_id}: counterpart timestamp provenance is missing, unusable, or not bound to the cited CCM record" + )); + } + } + + failures +} + +fn scenario_semantic_failures( + scenario_dir: &Path, + manifest: &Value, + expected: &Value, + contract: &ScenarioContract, +) -> Vec { + let mut failures = + manifest_expected_binding_failures(scenario_dir, manifest, expected, contract.name); + failures.extend(expected_boundary_failures(expected, contract)); + failures.extend(counterpart_source_failures( + scenario_dir, + manifest, + expected, + )); + failures +} + +fn manifest_artifact_failures(scenario_dir: &Path, artifact: &Value) -> Vec { + let mut failures = Vec::new(); + let artifact_id = artifact["artifactId"].as_str().unwrap_or(""); + let state = artifact["captureState"].as_str().unwrap_or(""); + let fragment_complete_field = artifact + .get("rotation") + .and_then(Value::as_object) + .and_then(|rotation| rotation.get("fragmentComplete")); + let fragment_complete = fragment_complete_field.and_then(Value::as_bool); + let physical = matches!(state, "captured" | "capped"); + + if physical + && artifact["pathFingerprint"] + .as_str() + .is_none_or(|fingerprint| fingerprint.trim().is_empty()) + { + failures.push(format!( + "{artifact_id}: physical artifact must have non-empty pathFingerprint" + )); + } + if physical && fragment_complete.is_none() { + failures.push(format!( + "{artifact_id}: physical artifact must declare fragmentComplete" + )); + } + if matches!(state, "absent" | "skipped") && fragment_complete_field.is_some() { + failures.push(format!( + "{artifact_id}: nonphysical rotation fragmentComplete must be omitted for {state}" + )); + } + if matches!( + state, + "absent" | "accessDenied" | "capped" | "skipped" | "unsupported" | "parseFailed" + ) && fragment_complete == Some(true) + { + failures.push(format!( + "{artifact_id}: {state} coverage cannot claim a complete fragment" + )); + } + + if physical { + let Some(relative_path) = artifact["relativePath"].as_str() else { + return vec![format!( + "{artifact_id}: {state} artifact must have relativePath" + )]; + }; + let relative = Path::new(relative_path); + if !safe_evidence_relative_path(relative_path) { + failures.push(format!( + "{artifact_id}: unsafe relativePath {relative_path}" + )); + return failures; + } + let fixture = scenario_dir.join(relative); + if !fixture.is_file() { + failures.push(format!( + "{artifact_id}: relativePath does not resolve: {}", + fixture.display() + )); + return failures; + } + let actual = std::fs::metadata(&fixture) + .expect("evidence metadata is readable") + .len(); + if artifact["bytesCopied"].as_u64() != Some(actual) { + failures.push(format!( + "{artifact_id}: bytesCopied {:?} does not match {actual}", + artifact["bytesCopied"].as_u64() + )); + } + if artifact["encoding"] != "utf-8" { + failures.push(format!("{artifact_id}: physical evidence must be UTF-8")); + } + let byte_limit = match artifact["collectionLimit"]["byteLimit"].as_u64() { + Some(byte_limit) => byte_limit, + None => { + failures.push(format!( + "{artifact_id}: physical artifact must declare byteLimit" + )); + 0 + } + }; + if byte_limit < actual { + failures.push(format!( + "{artifact_id}: byteLimit {byte_limit} is below {actual}" + )); + } + if state == "capped" + && (artifact["collectionLimit"]["limitApplied"] != true + || artifact["truncated"] != true + || byte_limit != actual) + { + failures.push(format!( + "{artifact_id}: capped evidence must pin the applied exact limit" + )); + } + let basename = Path::new(relative_path) + .file_name() + .expect("relative evidence path has a basename") + .to_string_lossy(); + if artifact["originalBasename"].as_str() != Some(basename.as_ref()) { + failures.push(format!( + "{artifact_id}: originalBasename does not match physical file" + )); + } + if !artifact["sanitizedSourcePath"] + .as_str() + .is_some_and(|value| { + value.starts_with("SYNTHETIC://") && value.ends_with(basename.as_ref()) + }) + { + failures.push(format!( + "{artifact_id}: sanitized provenance must be an exact synthetic basename path" + )); + } + } else if matches!( + state, + "absent" | "accessDenied" | "skipped" | "unsupported" | "parseFailed" + ) { + if !artifact["relativePath"].is_null() || artifact["bytesCopied"].as_u64() != Some(0) { + failures.push(format!( + "{artifact_id}: {state} artifact cannot claim physical bytes" + )); + } + for field in ["encoding", "collectionLimit", "truncated"] { + if !artifact[field].is_null() { + failures.push(format!( + "{artifact_id}: noncapture artifact cannot carry physical {field} metadata" + )); + } + } + } else { + failures.push(format!("{artifact_id}: unknown captureState {state}")); + } + + failures +} + +fn visit_files(root: &Path, files: &mut Vec) { + if !root.exists() { + return; + } + for entry in std::fs::read_dir(root).expect("fixture directory is readable") { + let path = entry.expect("fixture entry is readable").path(); + if path.is_dir() { + visit_files(&path, files); + } else if path.is_file() { + files.push(path); + } + } +} + +fn collect_evidence_refs(value: &Value, refs: &mut Vec<(String, u64, u64)>) { + match value { + Value::Object(map) => { + if let (Some(artifact_id), Some(start_line), Some(end_line)) = ( + map.get("artifactId").and_then(Value::as_str), + map.get("startLine").and_then(Value::as_u64), + map.get("endLine").and_then(Value::as_u64), + ) { + refs.push((artifact_id.to_owned(), start_line, end_line)); + } + for child in map.values() { + collect_evidence_refs(child, refs); + } + } + Value::Array(values) => { + for child in values { + collect_evidence_refs(child, refs); + } + } + _ => {} + } +} + +fn fnv1a64(bytes: &[u8], mut hash: u64) -> u64 { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + let bit_length = (bytes.len() as u64) + .checked_mul(8) + .expect("fixture byte length fits SHA-256"); + let mut padded = bytes.to_vec(); + padded.push(0x80); + while padded.len() % 64 != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_length.to_be_bytes()); + + let mut state = [ + 0x6a09e667u32, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19, + ]; + for chunk in padded.chunks_exact(64) { + let mut words = [0u32; 64]; + for (index, word) in words.iter_mut().take(16).enumerate() { + let offset = index * 4; + *word = u32::from_be_bytes([ + chunk[offset], + chunk[offset + 1], + chunk[offset + 2], + chunk[offset + 3], + ]); + } + for index in 16..64 { + let sigma0 = words[index - 15].rotate_right(7) + ^ words[index - 15].rotate_right(18) + ^ (words[index - 15] >> 3); + let sigma1 = words[index - 2].rotate_right(17) + ^ words[index - 2].rotate_right(19) + ^ (words[index - 2] >> 10); + words[index] = words[index - 16] + .wrapping_add(sigma0) + .wrapping_add(words[index - 7]) + .wrapping_add(sigma1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = state; + for index in 0..64 { + let sum1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let temporary1 = h + .wrapping_add(sum1) + .wrapping_add(choose) + .wrapping_add(SHA256_ROUND_CONSTANTS[index]) + .wrapping_add(words[index]); + let sum0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temporary2 = sum0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temporary1); + d = c; + c = b; + b = a; + a = temporary1.wrapping_add(temporary2); + } + + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + state[5] = state[5].wrapping_add(f); + state[6] = state[6].wrapping_add(g); + state[7] = state[7].wrapping_add(h); + } + + let mut digest = [0u8; 32]; + for (index, word) in state.iter().enumerate() { + digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + digest +} + +fn hex_digest(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[test] +fn software_update_fixture_matrix_pins_independent_conservative_outcomes() { + let actual_scenarios = scenario_directories(); + let expected_scenarios = SCENARIOS + .iter() + .map(|contract| contract.name.to_owned()) + .collect::>(); + assert_eq!( + actual_scenarios, expected_scenarios, + "#323 scenario matrix changed" + ); + + let mut failures = Vec::new(); + for contract in &SCENARIOS { + let scenario_dir = updates_root().join(contract.name); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let expected = read_json(&scenario_dir.join("expected.json")); + + failures.extend(scenario_semantic_failures( + &scenario_dir, + &manifest, + &expected, + contract, + )); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn software_update_fixture_bytes_paths_lines_and_ccm_records_are_exact() { + let mut failures = Vec::new(); + let mut artifact_count = 0; + let mut declared_files = BTreeSet::new(); + let mut physical_files = Vec::new(); + let mut corpus_items = Vec::new(); + let mut physical_bytes = 0u64; + let mut physical_lines = 0u64; + let mut complete_ccm_records = 0usize; + let mut partial_files = 0usize; + let mut capped_files = 0usize; + + for contract in &SCENARIOS { + let scenario_dir = updates_root().join(contract.name); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let expected = read_json(&scenario_dir.join("expected.json")); + let mut line_counts = BTreeMap::new(); + let artifacts = manifest["artifacts"] + .as_array() + .expect("manifest artifacts must be an array"); + artifact_count += artifacts.len(); + + for artifact in artifacts { + failures.extend( + manifest_artifact_failures(&scenario_dir, artifact) + .into_iter() + .map(|failure| format!("{}: {failure}", contract.name)), + ); + let artifact_id = match json_string(artifact, "artifactId") { + Ok(artifact_id) => artifact_id, + Err(error) => { + failures.push(format!("{}: manifest artifact {error}", contract.name)); + continue; + } + }; + let Some(relative_path) = artifact["relativePath"].as_str() else { + continue; + }; + let full_path = scenario_dir.join(relative_path); + declared_files.insert(full_path.clone()); + let bytes = std::fs::read(&full_path).unwrap_or_else(|error| { + panic!("{} must be readable: {error}", full_path.display()) + }); + physical_bytes += bytes.len() as u64; + if !bytes + .windows(b"SYNTHETIC FIXTURE".len()) + .any(|window| window == b"SYNTHETIC FIXTURE") + { + failures.push(format!( + "{}: {} lacks the synthetic marker", + contract.name, artifact_id + )); + } + let contents = std::str::from_utf8(&bytes) + .unwrap_or_else(|error| panic!("{} must be UTF-8: {error}", full_path.display())); + for prohibited in [ + "CONTOSO", + "C:\\", + "Bearer ", + "token=", + "TenantId=", + "UserSid=", + "Certificate=", + ] { + if contents.contains(prohibited) { + failures.push(format!( + "{}: {} contains prohibited evidence material {prohibited:?}", + contract.name, artifact_id + )); + } + } + for suffix in contents.split("SiteCode=").skip(1) { + let site_code = suffix + .chars() + .take_while(|character| character.is_ascii_alphanumeric()) + .collect::(); + if site_code != "LAB" { + failures.push(format!( + "{}: {} contains noncanonical site code {site_code:?}", + contract.name, artifact_id + )); + } + } + let lines = contents.lines().count() as u64; + physical_lines += lines; + line_counts.insert(artifact_id.clone(), lines); + if artifact["captureState"] == "capped" { + capped_files += 1; + } else if artifact["captureState"] == "captured" + && artifact["rotation"]["fragmentComplete"] == false + { + partial_files += 1; + } + + if artifact["kind"] == "ccmLog" { + let parsed = + parse_content_with_selection(contents, relative_path, &ResolvedParser::ccm()); + if artifact["rotation"]["fragmentComplete"] == true { + if parsed.parse_errors != 0 + || parsed.entries.len() as u64 != lines + || parsed + .entries + .iter() + .any(|entry| entry.format != LogFormat::Ccm) + { + failures.push(format!( + "{}: {} must contain complete CCM logical records", + contract.name, artifact_id + )); + } + complete_ccm_records += parsed.entries.len(); + } else if parsed.parse_errors == 0 { + failures.push(format!( + "{}: {} partial/capped fixture unexpectedly parsed complete", + contract.name, artifact_id + )); + } + } + + let relative_corpus_path = full_path + .strip_prefix(updates_root()) + .expect("evidence is below updates root") + .to_string_lossy() + .into_owned(); + corpus_items.push((relative_corpus_path, bytes)); + } + + let mut refs = Vec::new(); + collect_evidence_refs(&expected, &mut refs); + for (artifact_id, start_line, end_line) in refs { + let Some(line_count) = line_counts.get(&artifact_id) else { + failures.push(format!( + "{}: expected evidence references unknown/nonphysical artifact {}", + contract.name, artifact_id + )); + continue; + }; + if start_line == 0 || end_line < start_line || end_line > *line_count { + failures.push(format!( + "{}: {} has invalid line range {}-{} of {}", + contract.name, artifact_id, start_line, end_line, line_count + )); + } + } + visit_files(&scenario_dir.join("evidence"), &mut physical_files); + } + + assert_eq!( + artifact_count, EXPECTED_ARTIFACTS, + "#323 artifact matrix changed" + ); + physical_files.sort(); + assert_eq!( + physical_files.len(), + EXPECTED_PHYSICAL_FILES, + "#323 physical fixture count changed" + ); + assert_eq!( + declared_files.len(), + EXPECTED_PHYSICAL_FILES, + "#323 declared physical fixture count changed" + ); + let physical_set = physical_files.into_iter().collect::>(); + assert_eq!( + physical_set, declared_files, + "#323 evidence has a missing manifest reference or orphan file" + ); + assert_eq!( + physical_bytes, EXPECTED_PHYSICAL_BYTES, + "#323 physical evidence byte total drifted" + ); + assert_eq!( + physical_lines, EXPECTED_PHYSICAL_LINES, + "#323 physical evidence line total drifted" + ); + assert_eq!( + complete_ccm_records, EXPECTED_COMPLETE_CCM_RECORDS, + "#323 complete CCM record total drifted" + ); + assert_eq!( + partial_files, EXPECTED_PARTIAL_FILES, + "#323 partial-fragment file total drifted" + ); + assert_eq!( + capped_files, EXPECTED_CAPPED_FILES, + "#323 capped file total drifted" + ); + + corpus_items.sort_by(|left, right| left.0.cmp(&right.0)); + let per_file_hashes = corpus_items + .iter() + .map(|(relative_path, bytes)| format!("{relative_path} {}", hex_digest(&sha256(bytes)))) + .collect::>(); + let corpus_hash = + corpus_items + .iter() + .fold(0xcbf2_9ce4_8422_2325, |hash, (relative_path, bytes)| { + let hash = fnv1a64(relative_path.as_bytes(), hash); + let hash = fnv1a64(&[0], hash); + fnv1a64(bytes, hash) + }); + assert_eq!( + corpus_hash, + EXPECTED_CORPUS_FNV1A64, + "#323 path-qualified evidence corpus FNV drifted; per-file SHA-256:\n{}", + per_file_hashes.join("\n") + ); + let mut corpus_sha_input = Vec::new(); + for (relative_path, bytes) in &corpus_items { + corpus_sha_input.extend_from_slice(relative_path.as_bytes()); + corpus_sha_input.push(0); + corpus_sha_input.extend_from_slice(bytes); + } + assert_eq!( + hex_digest(&sha256(&corpus_sha_input)), + EXPECTED_CORPUS_SHA256, + "#323 path-qualified evidence corpus SHA-256 drifted; per-file SHA-256:\n{}", + per_file_hashes.join("\n") + ); + + let capped = std::fs::read( + updates_root().join("capped/evidence/client-content/current/DataTransferService.log"), + ) + .expect("capped update fixture is readable"); + assert_eq!( + capped, + EXPECTED_CAPPED_CONTENT, + "#323 capped content drifted; actual SHA-256 {}", + hex_digest(&sha256(&capped)) + ); + + let rotation_manifest = read_json(&updates_root().join("rotation-boundary/manifest.json")); + let rollovers = rotation_manifest["artifacts"] + .as_array() + .expect("rotation artifacts are an array") + .iter() + .filter(|artifact| artifact["rotation"]["kind"] == "lo") + .collect::>(); + assert_eq!(rollovers.len(), 1, "exactly one .lo_ rollover is allowed"); + let rollover = rollovers[0]; + assert_eq!(rollover["originalBasename"], "ScanAgent.lo_"); + assert_eq!( + rollover["relativePath"], + "evidence/client-updates/lo/ScanAgent.lo_" + ); + assert_eq!( + rollover["sanitizedSourcePath"], + "SYNTHETIC://root-a/CCM/Logs/ScanAgent.lo_" + ); + let rotation_dir = updates_root().join("rotation-boundary/evidence/client-updates"); + let current_fragment = std::fs::read_to_string(rotation_dir.join("current/ScanAgent.log")) + .expect("current rotation fragment is readable"); + let lo_fragment = std::fs::read_to_string(rotation_dir.join("lo/ScanAgent.lo_")) + .expect(".lo_ rotation fragment is readable"); + for joined in [ + format!("{current_fragment}{lo_fragment}"), + format!("{lo_fragment}{current_fragment}"), + ] { + let parsed = + parse_content_with_selection(&joined, "joined-rotation.log", &ResolvedParser::ccm()); + assert_eq!( + parsed.parse_errors, 2, + "physical rotation fragments must retain both CCM parse errors" + ); + assert!( + parsed + .entries + .iter() + .all(|entry| entry.format != LogFormat::Ccm), + "physical rotation fragments must never join into a logical CCM record" + ); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn software_update_fixture_contract_rejects_coverage_and_causality_mutations() { + let scenario_dir = updates_root().join("success"); + let mut wrong_site = read_json(&scenario_dir.join("manifest.json")); + wrong_site["bundle"]["siteCode"] = Value::String("ABC".to_owned()); + assert!(manifest_identity_failures(&wrong_site, "success") + .iter() + .any(|failure| failure.contains("manifest identity"))); + + let capped_dir = updates_root().join("capped"); + let capped_manifest = read_json(&capped_dir.join("manifest.json")); + let capped = capped_manifest["artifacts"] + .as_array() + .expect("capped artifacts are an array") + .iter() + .find(|artifact| artifact["captureState"] == "capped") + .expect("capped scenario has capped evidence"); + + let mut complete_capped = capped.clone(); + complete_capped["rotation"]["fragmentComplete"] = Value::Bool(true); + assert!(manifest_artifact_failures(&capped_dir, &complete_capped) + .iter() + .any(|failure| failure.contains("cannot claim a complete fragment"))); + + let mut unsafe_capped = capped.clone(); + unsafe_capped["relativePath"] = Value::String("../DataTransferService.log".to_owned()); + assert!(manifest_artifact_failures(&capped_dir, &unsafe_capped) + .iter() + .any(|failure| failure.contains("unsafe relativePath"))); + + let access_dir = updates_root().join("access-denied"); + let access_manifest = read_json(&access_dir.join("manifest.json")); + let denied = access_manifest["artifacts"] + .as_array() + .expect("access artifacts are an array") + .iter() + .find(|artifact| artifact["captureState"] == "accessDenied") + .expect("access scenario has denied evidence"); + let mut denied_with_bytes = denied.clone(); + denied_with_bytes["relativePath"] = Value::String("evidence/denied.log".to_owned()); + denied_with_bytes["bytesCopied"] = Value::from(1); + assert!(manifest_artifact_failures(&access_dir, &denied_with_bytes) + .iter() + .any(|failure| failure.contains("cannot claim physical bytes"))); + + let mut time_only = read_json(&updates_root().join("success/expected.json")); + time_only["correlationHandoff"]["timeOnlyEligible"] = Value::Bool(true); + assert!(expected_boundary_failures( + &time_only, + SCENARIOS + .iter() + .find(|contract| contract.name == "success") + .expect("success contract exists") + ) + .iter() + .any(|failure| failure.contains("correlation handoff boundary"))); + + let mut policy_dependent = read_json(&updates_root().join("success/expected.json")); + policy_dependent["analysisContract"]["policyOutputRequired"] = Value::Bool(true); + assert!(expected_boundary_failures( + &policy_dependent, + SCENARIOS + .iter() + .find(|contract| contract.name == "success") + .expect("success contract exists") + ) + .iter() + .any(|failure| failure.contains("independent and client-only"))); + + let mut merged = read_json(&updates_root().join("same-minute-separate/expected.json")); + merged["transactions"] + .as_array_mut() + .expect("same-minute transactions are an array") + .pop(); + assert!(expected_boundary_failures( + &merged, + SCENARIOS + .iter() + .find(|contract| contract.name == "same-minute-separate") + .expect("same-minute contract exists") + ) + .iter() + .any(|failure| failure.contains("transactions/observations/findings"))); + + let success_dir = updates_root().join("success"); + let success_manifest = read_json(&success_dir.join("manifest.json")); + let mut wrong_sup_source = read_json(&success_dir.join("expected.json")); + wrong_sup_source["correlationHandoff"]["counterpartReadyFacts"][0]["evidence"]["artifactId"] = + Value::String("updates-success-01-scan".to_owned()); + assert!( + counterpart_source_failures(&success_dir, &success_manifest, &wrong_sup_source) + .iter() + .any(|failure| failure.contains("explicit LocationServices LocateSup evidence")) + ); +} + +#[test] +fn software_update_fixture_contract_rejects_review_adversarial_mutations() { + fn failures_for(scenario: &str, manifest: &Value, expected: &Value) -> Vec { + let contract = SCENARIOS + .iter() + .find(|contract| contract.name == scenario) + .expect("scenario contract exists"); + scenario_semantic_failures(&updates_root().join(scenario), manifest, expected, contract) + } + + fn assert_rejected(failures: &[String], marker: &str) { + assert!( + failures.iter().any(|failure| failure.contains(marker)), + "expected rejection containing {marker:?}, got:\n{}", + failures.join("\n") + ); + } + + let success_dir = updates_root().join("success"); + let success_manifest = read_json(&success_dir.join("manifest.json")); + let success_expected = read_json(&success_dir.join("expected.json")); + + let mut wrong_key = success_expected.clone(); + wrong_key["transactions"][0]["key"]["updateId"] = + Value::String("32300000-0000-0000-0000-000000009999".to_owned()); + wrong_key["transactions"][0]["key"]["ciId"] = Value::String("CI-DRIFT".to_owned()); + wrong_key["transactions"][0]["key"]["updateJobId"] = Value::String("JOB-DRIFT".to_owned()); + assert_rejected( + &failures_for("success", &success_manifest, &wrong_key), + "exact transaction key", + ); + + let mut foreign_citation = success_expected.clone(); + foreign_citation["transactions"][0]["evidence"][0]["artifactId"] = + Value::String("updates-access-denied-01-scan".to_owned()); + assert_rejected( + &failures_for("success", &success_manifest, &foreign_citation), + "same-scenario citation", + ); + + let mut conflicting_coverage = success_expected.clone(); + conflicting_coverage["coverage"] + .as_array_mut() + .expect("coverage is an array") + .push(serde_json::json!({ + "logicalArtifactId": "client-updates", + "state": "absent" + })); + assert_rejected( + &failures_for("success", &success_manifest, &conflicting_coverage), + "coverage projection", + ); + + let mut wrong_gap = success_expected.clone(); + wrong_gap["transactions"][0]["coverageGapArtifactIds"] = serde_json::json!(["client-updates"]); + assert_rejected( + &failures_for("success", &success_manifest, &wrong_gap), + "coverage gaps", + ); + + let mut wrong_provenance = success_expected.clone(); + wrong_provenance["artifactProvenance"][0]["captureState"] = Value::String("absent".to_owned()); + assert_rejected( + &failures_for("success", &success_manifest, &wrong_provenance), + "artifact provenance", + ); + + let mut wrong_kind_manifest = success_manifest.clone(); + wrong_kind_manifest["artifacts"][0]["kind"] = Value::String("cbsLog".to_owned()); + assert_rejected( + &failures_for("success", &wrong_kind_manifest, &success_expected), + "artifact kind", + ); + + let mut wrong_version_manifest = success_manifest.clone(); + wrong_version_manifest["artifacts"][1]["sourceVersion"] = + Value::String("9.99.UNKNOWN".to_owned()); + assert_rejected( + &failures_for("success", &wrong_version_manifest, &success_expected), + "source profile/version", + ); + + let mut bogus_timestamp = success_expected.clone(); + bogus_timestamp["correlationHandoff"]["counterpartReadyFacts"][0]["timestampProvenance"] = serde_json::json!({ + "normalizedUtc": "2099-01-01T00:00:00.000Z", + "utcMillis": 4070908800000_i64, + "offsetMinutes": 840, + "orderingState": "normalizedUtc" + }); + assert_rejected( + &failures_for("success", &success_manifest, &bogus_timestamp), + "counterpart timestamp provenance", + ); + + let mut prefix_key = success_expected.clone(); + prefix_key["correlationHandoff"]["counterpartReadyFacts"][0]["ciId"] = + Value::String("CI-UPDATE".to_owned()); + assert_rejected( + &failures_for("success", &success_manifest, &prefix_key), + "exact counterpart key", + ); + + let mut wrong_site = success_manifest.clone(); + wrong_site["bundle"]["siteCode"] = Value::String("ABC".to_owned()); + assert_rejected( + &failures_for("success", &wrong_site, &success_expected), + "manifest identity", + ); + + let supplemental_dir = updates_root().join("supplemental-conflict"); + let supplemental_manifest = read_json(&supplemental_dir.join("manifest.json")); + let mut elevated_supplemental = read_json(&supplemental_dir.join("expected.json")); + elevated_supplemental["sourceLocalObservations"][0]["confidence"] = + Value::String("high".to_owned()); + elevated_supplemental["sourceLocalObservations"][0]["confidenceCeiling"] = + Value::String("high".to_owned()); + elevated_supplemental["findings"][0]["confidence"] = Value::String("high".to_owned()); + elevated_supplemental["findings"][0]["confidenceCeiling"] = Value::String("high".to_owned()); + assert_rejected( + &failures_for( + "supplemental-conflict", + &supplemental_manifest, + &elevated_supplemental, + ), + "conservative confidence", + ); + + let invalid_dir = updates_root().join("invalid-offset"); + let invalid_manifest = read_json(&invalid_dir.join("manifest.json")); + let mut elevated_invalid = read_json(&invalid_dir.join("expected.json")); + elevated_invalid["findings"][0]["confidence"] = Value::String("high".to_owned()); + elevated_invalid["findings"][0]["confidenceCeiling"] = Value::String("high".to_owned()); + assert_rejected( + &failures_for("invalid-offset", &invalid_manifest, &elevated_invalid), + "conservative confidence", + ); + + let same_minute_dir = updates_root().join("same-minute-separate"); + let same_minute_manifest = read_json(&same_minute_dir.join("manifest.json")); + let mut merged_outcome = read_json(&same_minute_dir.join("expected.json")); + merged_outcome["transactions"][1]["state"] = Value::String("succeeded".to_owned()); + merged_outcome["transactions"][1]["classification"] = Value::String("success".to_owned()); + assert_rejected( + &failures_for( + "same-minute-separate", + &same_minute_manifest, + &merged_outcome, + ), + "same-minute transaction outcomes", + ); + + let no_sup_dir = updates_root().join("no-sup"); + let no_sup_manifest = read_json(&no_sup_dir.join("manifest.json")); + let no_sup_expected = read_json(&no_sup_dir.join("expected.json")); + let mut invented_sup = no_sup_expected.clone(); + invented_sup["transactions"][0]["key"]["supHostHandle"] = + Value::String("safe:sup:lab-sup-01".to_owned()); + assert_rejected( + &failures_for("no-sup", &no_sup_manifest, &invented_sup), + "SUP handle without LocateSup", + ); + + let mut nonphysical_fragment = no_sup_manifest.clone(); + nonphysical_fragment["artifacts"][1]["rotation"]["fragmentComplete"] = Value::Bool(false); + assert_rejected( + &failures_for("no-sup", &nonphysical_fragment, &no_sup_expected), + "nonphysical rotation fragmentComplete", + ); +} + +#[test] +fn software_update_fixture_rejects_report_success_without_report_evidence() { + let scenario = "success"; + let scenario_dir = updates_root().join(scenario); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let mut expected = read_json(&scenario_dir.join("expected.json")); + expected["transactions"][0]["evidence"] = serde_json::json!([ + { + "artifactId": "updates-success-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-success-02-sup", + "startLine": 1, + "endLine": 1 + } + ]); + let contract = SCENARIOS + .iter() + .find(|contract| contract.name == scenario) + .expect("success contract exists"); + let failures = scenario_semantic_failures(&scenario_dir, &manifest, &expected, contract); + assert!( + failures + .iter() + .any(|failure| failure.contains("phase outcome evidence")), + "Report/High success without Report evidence was accepted:\n{}", + failures.join("\n") + ); +} + +#[test] +fn software_update_fixture_rejects_install_failure_without_terminal_evidence() { + let scenario = "install-failure"; + let scenario_dir = updates_root().join(scenario); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let mut expected = read_json(&scenario_dir.join("expected.json")); + let reduced_evidence = serde_json::json!([ + { + "artifactId": "updates-install-failure-01-scan", + "startLine": 1, + "endLine": 2 + }, + { + "artifactId": "updates-install-failure-02-sup", + "startLine": 1, + "endLine": 1 + } + ]); + expected["transactions"][0]["evidence"] = reduced_evidence.clone(); + expected["findings"][0]["evidence"] = reduced_evidence; + let contract = SCENARIOS + .iter() + .find(|contract| contract.name == scenario) + .expect("install-failure contract exists"); + let failures = scenario_semantic_failures(&scenario_dir, &manifest, &expected, contract); + assert!( + failures + .iter() + .any(|failure| failure.contains("phase outcome evidence")), + "Install/High confirmedFailure without terminal evidence was accepted:\n{}", + failures.join("\n") + ); +} + +#[test] +fn software_update_fixture_rejects_cbs_log_with_configmgr_source_version() { + let scenario = "supplemental-conflict"; + let scenario_dir = updates_root().join(scenario); + let base_manifest = read_json(&scenario_dir.join("manifest.json")); + let expected = read_json(&scenario_dir.join("expected.json")); + let contract = SCENARIOS + .iter() + .find(|contract| contract.name == scenario) + .expect("supplemental-conflict contract exists"); + + let mut versioned_cbs = base_manifest.clone(); + assert_eq!( + versioned_cbs["artifacts"][1]["originalBasename"], "CBS.log", + "mutation must target the CBS.log artifact" + ); + versioned_cbs["artifacts"][1]["sourceVersion"] = Value::String("5.00.TEST.0000".to_owned()); + + let direct = manifest_artifact_kind_failures(&versioned_cbs["artifacts"][1]); + assert!( + direct + .iter() + .any(|failure| failure.contains("cbsLog sourceVersion must be null")), + "cbsLog with a ConfigMgr sourceVersion was accepted by kind validation:\n{}", + direct.join("\n") + ); + + let failures = scenario_semantic_failures(&scenario_dir, &versioned_cbs, &expected, contract); + assert!( + failures + .iter() + .any(|failure| failure.contains("cbsLog sourceVersion must be null")), + "cbsLog with a ConfigMgr sourceVersion validated clean:\n{}", + failures.join("\n") + ); + + assert!( + base_manifest["artifacts"][1]["sourceVersion"].is_null(), + "shipped CBS.log fixture must not carry a ConfigMgr sourceVersion" + ); +} + +#[test] +fn software_update_fixture_rejects_physical_metadata_on_noncapture_artifacts() { + let marker = "noncapture artifact cannot carry physical"; + let mut missing_rejections = Vec::new(); + + let incomplete_scenario = "incomplete"; + let incomplete_dir = updates_root().join(incomplete_scenario); + let incomplete_manifest = read_json(&incomplete_dir.join("manifest.json")); + let incomplete_expected = read_json(&incomplete_dir.join("expected.json")); + let incomplete_contract = SCENARIOS + .iter() + .find(|contract| contract.name == incomplete_scenario) + .expect("incomplete contract exists"); + assert_eq!( + incomplete_manifest["artifacts"][1]["captureState"], "absent", + "mutation must target an absent artifact" + ); + + for (label, field, value) in [ + ("stale encoding", "encoding", serde_json::json!("utf-8")), + ( + "stale collectionLimit", + "collectionLimit", + serde_json::json!({"byteLimit": 4096, "limitApplied": false}), + ), + ("stale truncated", "truncated", serde_json::json!(false)), + ] { + let mut manifest = incomplete_manifest.clone(); + manifest["artifacts"][1][field] = value; + let failures = scenario_semantic_failures( + &incomplete_dir, + &manifest, + &incomplete_expected, + incomplete_contract, + ); + if !failures.iter().any(|failure| failure.contains(marker)) { + missing_rejections.push(format!( + "{label} on absent artifact: {}", + failures.join(" | ") + )); + } + } + + let access_scenario = "access-denied"; + let access_dir = updates_root().join(access_scenario); + let access_manifest = read_json(&access_dir.join("manifest.json")); + let access_expected = read_json(&access_dir.join("expected.json")); + let access_contract = SCENARIOS + .iter() + .find(|contract| contract.name == access_scenario) + .expect("access-denied contract exists"); + assert_eq!( + access_manifest["artifacts"][1]["captureState"], "accessDenied", + "mutation must target an access-denied artifact" + ); + let mut denied_with_encoding = access_manifest.clone(); + denied_with_encoding["artifacts"][1]["encoding"] = serde_json::json!("utf-8"); + let failures = scenario_semantic_failures( + &access_dir, + &denied_with_encoding, + &access_expected, + access_contract, + ); + if !failures.iter().any(|failure| failure.contains(marker)) { + missing_rejections.push(format!( + "stale encoding on access-denied artifact: {}", + failures.join(" | ") + )); + } + + assert!( + missing_rejections.is_empty(), + "noncapture artifacts accepted stale physical metadata:\n{}", + missing_rejections.join("\n") + ); +} + +#[test] +fn software_update_fixture_rejects_duplicate_and_overlapping_citations() { + let success_scenario = "success"; + let success_dir = updates_root().join(success_scenario); + let success_manifest = read_json(&success_dir.join("manifest.json")); + let success_expected = read_json(&success_dir.join("expected.json")); + let success_contract = SCENARIOS + .iter() + .find(|contract| contract.name == success_scenario) + .expect("success contract exists"); + let marker = "overlap and double-count"; + let mut missing_rejections = Vec::new(); + + let mut duplicated_tuple = success_expected.clone(); + let duplicate = duplicated_tuple["transactions"][0]["evidence"][0].clone(); + duplicated_tuple["transactions"][0]["evidence"] + .as_array_mut() + .expect("success transaction evidence is an array") + .push(duplicate); + let failures = scenario_semantic_failures( + &success_dir, + &success_manifest, + &duplicated_tuple, + success_contract, + ); + if !failures.iter().any(|failure| failure.contains(marker)) { + missing_rejections.push(format!( + "duplicate transaction citation tuple: {}", + failures.join(" | ") + )); + } + + let mut overlapping_range = success_expected.clone(); + overlapping_range["transactions"][0]["evidence"] + .as_array_mut() + .expect("success transaction evidence is an array") + .push(serde_json::json!({ + "artifactId": "updates-success-01-scan", + "startLine": 1, + "endLine": 1 + })); + let failures = scenario_semantic_failures( + &success_dir, + &success_manifest, + &overlapping_range, + success_contract, + ); + if !failures.iter().any(|failure| failure.contains(marker)) { + missing_rejections.push(format!( + "overlapping transaction citation range: {}", + failures.join(" | ") + )); + } + + let supplemental_scenario = "supplemental-conflict"; + let supplemental_dir = updates_root().join(supplemental_scenario); + let supplemental_manifest = read_json(&supplemental_dir.join("manifest.json")); + let supplemental_contract = SCENARIOS + .iter() + .find(|contract| contract.name == supplemental_scenario) + .expect("supplemental-conflict contract exists"); + let mut duplicated_observation = read_json(&supplemental_dir.join("expected.json")); + let citation = duplicated_observation["sourceLocalObservations"][0]["evidence"][0].clone(); + for subject_path in ["sourceLocalObservations", "findings"] { + duplicated_observation[subject_path][0]["evidence"] + .as_array_mut() + .expect("supplemental subject evidence is an array") + .push(citation.clone()); + } + let failures = scenario_semantic_failures( + &supplemental_dir, + &supplemental_manifest, + &duplicated_observation, + supplemental_contract, + ); + if !failures.iter().any(|failure| failure.contains(marker)) { + missing_rejections.push(format!( + "duplicate observation citation tuple: {}", + failures.join(" | ") + )); + } + + assert!( + missing_rejections.is_empty(), + "duplicate or overlapping citations validated clean:\n{}", + missing_rejections.join("\n") + ); +} + +#[test] +fn software_update_fixture_rejects_keyless_observation_phase_success_claims() { + let scenario = "supplemental-conflict"; + let scenario_dir = updates_root().join(scenario); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let base_expected = read_json(&scenario_dir.join("expected.json")); + let contract = SCENARIOS + .iter() + .find(|contract| contract.name == scenario) + .expect("supplemental-conflict contract exists"); + + let mut phase_claiming = base_expected.clone(); + phase_claiming["sourceLocalObservations"][0]["lastSuccessfulPhase"] = + Value::String("install".to_owned()); + phase_claiming["findings"][0]["lastSuccessfulPhase"] = Value::String("install".to_owned()); + let failures = scenario_semantic_failures(&scenario_dir, &manifest, &phase_claiming, contract); + assert!( + failures.iter().any(|failure| { + failure.contains("keyless observation cannot claim a lastSuccessfulPhase") + }), + "null-key observation claiming lastSuccessfulPhase validated clean:\n{}", + failures.join("\n") + ); + + assert!( + base_expected["sourceLocalObservations"][0]["lastSuccessfulPhase"].is_null() + && base_expected["findings"][0]["lastSuccessfulPhase"].is_null(), + "shipped supplemental-conflict observation/finding must not claim a lastSuccessfulPhase" + ); +} + +#[test] +fn software_update_fixture_rejects_cross_record_exact_key_chimeras() { + let scenario_dir = updates_root().join("same-minute-separate"); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let mut expected = read_json(&scenario_dir.join("expected.json")); + expected["transactions"][0]["evidence"][0]["endLine"] = Value::from(2); + for field in ["ciId", "contentId", "updateJobId", "clientHandle"] { + expected["transactions"][0]["key"][field] = + expected["transactions"][1]["key"][field].clone(); + } + let (index, index_failures) = evidence_index(&scenario_dir, &manifest); + assert!(index_failures.is_empty(), "{}", index_failures.join("\n")); + let failures = transaction_binding_failures("same-minute-separate", &expected, &index); + assert!( + failures + .iter() + .any(|failure| failure.contains("complete exact key tuple")), + "same-minute cross-record key chimera was accepted:\n{}", + failures.join("\n") + ); +} + +#[test] +fn software_update_fixture_rejects_manifest_aliases_and_identity_drift() { + let scenario = "success"; + let scenario_dir = updates_root().join(scenario); + let base_manifest = read_json(&scenario_dir.join("manifest.json")); + let base_expected = read_json(&scenario_dir.join("expected.json")); + let contract = SCENARIOS + .iter() + .find(|contract| contract.name == scenario) + .expect("success contract exists"); + let mut missing_rejections = Vec::new(); + let mut require_rejection = |label: &str, manifest: &Value, expected: &Value, marker: &str| { + let failures = scenario_semantic_failures(&scenario_dir, manifest, expected, contract); + if !failures.iter().any(|failure| failure.contains(marker)) { + missing_rejections.push(format!( + "{label} (wanted {marker:?}; got {})", + failures.join(" | ") + )); + } + }; + + let mut duplicate_group = base_manifest.clone(); + duplicate_group["artifacts"][0]["designOnlyCatalog"]["groupMemberships"] + .as_array_mut() + .expect("group memberships are an array") + .push(Value::String("client-updates".to_owned())); + require_rejection( + "duplicate group alias", + &duplicate_group, + &base_expected, + "group memberships", + ); + + let mut duplicate_artifact = base_manifest.clone(); + let mut artifact_alias = duplicate_artifact["artifacts"][0].clone(); + artifact_alias["artifactId"] = Value::String("updates-success-09-scan-alias".to_owned()); + duplicate_artifact["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .push(artifact_alias); + let mut alias_expected = base_expected.clone(); + let mut provenance_alias = alias_expected["artifactProvenance"][0].clone(); + provenance_alias["artifactId"] = Value::String("updates-success-09-scan-alias".to_owned()); + alias_expected["artifactProvenance"] + .as_array_mut() + .expect("artifact provenance is an array") + .push(provenance_alias); + require_rejection( + "relativePath/pathFingerprint artifact alias", + &duplicate_artifact, + &alias_expected, + "duplicate physical alias", + ); + + let mut server_role = base_manifest.clone(); + server_role["artifacts"][0]["role"] = Value::String("server".to_owned()); + require_rejection( + "server role in client corpus", + &server_role, + &base_expected, + "artifact role", + ); + + let mut catalog_substitution = base_manifest.clone(); + catalog_substitution["artifacts"][6]["designOnlyCatalog"]["entryId"] = + Value::String("client-content".to_owned()); + let mut profile_substitution = base_expected.clone(); + profile_substitution["extractionProfile"]["validatedArtifactFamilies"] + .as_array_mut() + .expect("validated families are an array") + .retain(|family| family != "client-policy-state"); + require_rejection( + "catalog entry/profile family substitution", + &catalog_substitution, + &profile_substitution, + "catalog entry/logical group", + ); + + let mut wrong_rotation = base_manifest.clone(); + wrong_rotation["artifacts"][0]["rotation"]["kind"] = Value::String("lo".to_owned()); + require_rejection( + "rotation/path mismatch", + &wrong_rotation, + &base_expected, + "rotation kind", + ); + + let mut redirected_report = base_manifest.clone(); + let scan = redirected_report["artifacts"][0].clone(); + for field in [ + "relativePath", + "originalBasename", + "sanitizedSourcePath", + "bytesCopied", + "encoding", + "collectionLimit", + "sourceVersion", + "rotation", + ] { + redirected_report["artifacts"][6][field] = scan[field].clone(); + } + require_rejection( + "report artifact redirected to scan path", + &redirected_report, + &base_expected, + "relativePath is incompatible", + ); + + assert!( + missing_rejections.is_empty(), + "semantic validator accepted manifest drift:\n{}", + missing_rejections.join("\n") + ); +} + +#[test] +fn software_update_fixture_rejects_missing_or_blank_physical_path_fingerprints() { + let scenario = "success"; + let scenario_dir = updates_root().join(scenario); + let base_manifest = read_json(&scenario_dir.join("manifest.json")); + let expected = read_json(&scenario_dir.join("expected.json")); + let contract = SCENARIOS + .iter() + .find(|contract| contract.name == scenario) + .expect("success contract exists"); + let mut missing_rejections = Vec::new(); + + let mut missing = base_manifest.clone(); + missing["artifacts"][0] + .as_object_mut() + .expect("artifact is an object") + .remove("pathFingerprint"); + let mut mutations = vec![("missing", missing)]; + for (label, value) in [ + ("null", Value::Null), + ("empty", Value::String(String::new())), + ("blank", Value::String(" \t".to_owned())), + ] { + let mut manifest = base_manifest.clone(); + manifest["artifacts"][0]["pathFingerprint"] = value; + mutations.push((label, manifest)); + } + + for (label, manifest) in mutations { + let failures = scenario_semantic_failures(&scenario_dir, &manifest, &expected, contract); + if !failures.iter().any(|failure| { + failure.contains("physical artifact must have non-empty pathFingerprint") + }) { + missing_rejections.push(format!("{label}: {}", failures.join(" | "))); + } + } + + assert!( + missing_rejections.is_empty(), + "physical artifact fingerprint mutations were accepted:\n{}", + missing_rejections.join("\n") + ); +} + +#[test] +fn software_update_fixture_rejects_unevaluated_topology_claims_and_eligibility() { + let scenario = "success"; + let scenario_dir = updates_root().join(scenario); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let base_expected = read_json(&scenario_dir.join("expected.json")); + let contract = SCENARIOS + .iter() + .find(|contract| contract.name == scenario) + .expect("success contract exists"); + let mut missing_rejections = Vec::new(); + + for (label, value) in [ + ("compatible", Value::Bool(true)), + ("incompatible", Value::Bool(false)), + ("malformed-string", Value::String("unknown".to_owned())), + ("malformed-null", Value::Null), + ] { + let mut expected = base_expected.clone(); + expected["correlationHandoff"]["counterpartReadyFacts"][0]["correlationEligible"] = + Value::Bool(false); + expected["correlationHandoff"]["counterpartReadyFacts"][0]["topologyCompatible"] = value; + let failures = scenario_semantic_failures(&scenario_dir, &manifest, &expected, contract); + if !failures + .iter() + .any(|failure| failure.contains("unevaluated topology compatibility")) + { + missing_rejections.push(format!("{label}: {}", failures.join(" | "))); + } + } + + for label in ["missing-evaluation", "null-evaluation"] { + let mut expected = base_expected.clone(); + expected["correlationHandoff"]["counterpartReadyFacts"][0]["correlationEligible"] = + Value::Bool(false); + expected["correlationHandoff"]["counterpartReadyFacts"][0]["topologyCompatible"] = + Value::Bool(true); + if label == "missing-evaluation" { + expected["correlationHandoff"] + .as_object_mut() + .expect("correlation handoff is an object") + .remove("topologyCompatibilityEvaluated"); + } else { + expected["correlationHandoff"]["topologyCompatibilityEvaluated"] = Value::Null; + } + let failures = counterpart_source_failures(&scenario_dir, &manifest, &expected); + if !failures + .iter() + .any(|failure| failure.contains("unevaluated topology compatibility")) + { + missing_rejections.push(format!("{label}: {}", failures.join(" | "))); + } + } + + let mut eligible = base_expected; + eligible["correlationHandoff"]["counterpartReadyFacts"][0] + .as_object_mut() + .expect("counterpart fact is an object") + .remove("topologyCompatible"); + eligible["correlationHandoff"]["counterpartReadyFacts"][0]["correlationEligible"] = + Value::Bool(true); + let failures = scenario_semantic_failures(&scenario_dir, &manifest, &eligible, contract); + if !failures + .iter() + .any(|failure| failure.contains("unevaluated topology compatibility")) + { + missing_rejections.push(format!( + "correlation-eligible without topology evaluation: {}", + failures.join(" | ") + )); + } + + assert!( + missing_rejections.is_empty(), + "unevaluated topology mutations were accepted:\n{}", + missing_rejections.join("\n") + ); +} + +#[test] +fn software_update_fixture_rejects_topology_mismatch_as_correlation_eligible() { + let scenario = "success"; + let scenario_dir = updates_root().join(scenario); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let mut expected = read_json(&scenario_dir.join("expected.json")); + expected["correlationHandoff"]["counterpartReadyFacts"][0]["topologyCompatible"] = + Value::Bool(false); + expected["correlationHandoff"]["counterpartReadyFacts"][0]["correlationEligible"] = + Value::Bool(true); + let failures = counterpart_source_failures(&scenario_dir, &manifest, &expected); + assert!( + failures + .iter() + .any(|failure| failure.contains("topology mismatch")), + "topology-incompatible correlation fact was accepted:\n{}", + failures.join("\n") + ); +} + +#[test] +fn software_update_fixture_never_elevates_experimental_low_keys_to_causal_confidence() { + let scenario_dir = updates_root().join("success"); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let expected = read_json(&scenario_dir.join("expected.json")); + let (index, failures) = evidence_index(&scenario_dir, &manifest); + assert!(failures.is_empty(), "{}", failures.join("\n")); + let evidence = index["updates-success-01-scan"] + .complete_ccm_records + .first() + .expect("success scan supplies one complete CCM record"); + let result = extract_keys( + evidence, + &SccmExtractionProfile::for_version(Some("5.00.9128.1007")), + ); + assert!(!result.keys.is_empty()); + assert!(result + .keys + .iter() + .all(|key| key.confidence == SccmKeyConfidence::Low)); + assert!(result + .gaps + .iter() + .any(|gap| gap.kind == SccmExtractionGapKind::ExperimentalProfile)); + + let mut elevated = expected; + elevated["extractionProfile"]["profileId"] = + Value::String(SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned()); + elevated["transactions"][0]["key"]["extractionProfileId"] = + Value::String(SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned()); + elevated["correlationHandoff"]["counterpartReadyFacts"][0]["extractionProfileId"] = + Value::String(SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned()); + assert!(experimental_profile_causality_failures(&elevated) + .iter() + .any(|failure| failure.contains("experimental Low key profile"))); +} + +#[test] +fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_mutations() { + fn validate_without_panicking( + scenario: &str, + manifest: &Value, + expected: &Value, + ) -> Result, String> { + std::panic::catch_unwind(|| { + let contract = SCENARIOS + .iter() + .find(|contract| contract.name == scenario) + .expect("scenario contract exists"); + scenario_semantic_failures(&updates_root().join(scenario), manifest, expected, contract) + }) + .map_err(|_| "validator panicked on caller-controlled JSON".to_owned()) + } + + fn set_subject_evidence(expected: &mut Value, evidence: Value) { + expected["transactions"][0]["evidence"] = evidence.clone(); + expected["findings"][0]["evidence"] = evidence; + } + + let mut mutations = Vec::<(&str, String, Value, Value, &str)>::new(); + + for (scenario, evidence) in [ + ( + "maintenance-window", + serde_json::json!([ + {"artifactId": "updates-maintenance-window-01-scan", "startLine": 1, "endLine": 2}, + {"artifactId": "updates-maintenance-window-02-sup", "startLine": 1, "endLine": 1}, + {"artifactId": "updates-maintenance-window-03-download", "startLine": 1, "endLine": 1} + ]), + ), + ( + "reboot-pending", + serde_json::json!([ + {"artifactId": "updates-reboot-pending-01-scan", "startLine": 1, "endLine": 2}, + {"artifactId": "updates-reboot-pending-02-sup", "startLine": 1, "endLine": 1}, + {"artifactId": "updates-reboot-pending-03-deployment", "startLine": 1, "endLine": 3} + ]), + ), + ] { + let scenario_dir = updates_root().join(scenario); + let manifest = read_json(&scenario_dir.join("manifest.json")); + let mut expected = read_json(&scenario_dir.join("expected.json")); + set_subject_evidence(&mut expected, evidence); + mutations.push(( + "blocked/deferred current phase evidence", + scenario.to_owned(), + manifest, + expected, + "phase/state evidence is missing", + )); + } + + let install_scenario = "install-failure"; + let install_dir = updates_root().join(install_scenario); + let install_manifest = read_json(&install_dir.join("manifest.json")); + let mut install_expected = read_json(&install_dir.join("expected.json")); + set_subject_evidence( + &mut install_expected, + serde_json::json!([ + {"artifactId": "updates-install-failure-01-scan", "startLine": 1, "endLine": 2}, + {"artifactId": "updates-install-failure-02-sup", "startLine": 1, "endLine": 1}, + {"artifactId": "updates-install-failure-03-download", "startLine": 1, "endLine": 1}, + {"artifactId": "updates-install-failure-04-install", "startLine": 2, "endLine": 2} + ]), + ); + mutations.push(( + "lastSuccessfulPhase evidence", + install_scenario.to_owned(), + install_manifest, + install_expected, + "last successful phase evidence is missing", + )); + + let success_scenario = "success"; + let success_dir = updates_root().join(success_scenario); + let success_manifest = read_json(&success_dir.join("manifest.json")); + let success_expected = read_json(&success_dir.join("expected.json")); + + let mut reporting_as_ccm = success_manifest.clone(); + reporting_as_ccm["artifacts"][7]["kind"] = Value::String("ccmLog".to_owned()); + reporting_as_ccm["artifacts"][7]["sourceVersion"] = Value::Null; + mutations.push(( + "ReportingEvents kind", + success_scenario.to_owned(), + reporting_as_ccm, + success_expected.clone(), + "ReportingEvents.log must use supplementalLog", + )); + + let mut reporting_with_ccm_version = success_manifest.clone(); + reporting_with_ccm_version["artifacts"][7]["kind"] = + Value::String("supplementalLog".to_owned()); + reporting_with_ccm_version["artifacts"][7]["sourceVersion"] = + Value::String("5.00.TEST.0000".to_owned()); + mutations.push(( + "ReportingEvents source version", + success_scenario.to_owned(), + reporting_with_ccm_version, + success_expected.clone(), + "supplementalLog sourceVersion must be null", + )); + + for fingerprint in [ + r"C:\Users\RealUser\ScanAgent.log", + "synthetic:corp.example", + "synthetic:updates-success-01-scan\ncontrol", + "synthetic:updates-success-02-sup", + ] { + let mut manifest = success_manifest.clone(); + manifest["artifacts"][0]["pathFingerprint"] = Value::String(fingerprint.to_owned()); + mutations.push(( + "private pathFingerprint", + success_scenario.to_owned(), + manifest, + success_expected.clone(), + "privacy-safe pathFingerprint", + )); + } + let mut non_string_fingerprint = success_manifest.clone(); + non_string_fingerprint["artifacts"][0]["pathFingerprint"] = Value::from(323); + mutations.push(( + "non-string pathFingerprint", + success_scenario.to_owned(), + non_string_fingerprint, + success_expected.clone(), + "privacy-safe pathFingerprint", + )); + + for source_path in [ + "SYNTHETIC://C:/Users/RealUser/ScanAgent.log", + "SYNTHETIC://corp.example/CCM/Logs/ScanAgent.log", + "SYNTHETIC://root-a/CCM/Logs/control\nScanAgent.log", + "SYNTHETIC://root-a/CCM/Logs/UpdatesHandler.log", + ] { + let mut manifest = success_manifest.clone(); + manifest["artifacts"][0]["sanitizedSourcePath"] = Value::String(source_path.to_owned()); + mutations.push(( + "private sanitizedSourcePath", + success_scenario.to_owned(), + manifest, + success_expected.clone(), + "privacy-safe sanitizedSourcePath", + )); + } + let mut non_string_source_path = success_manifest.clone(); + non_string_source_path["artifacts"][0]["sanitizedSourcePath"] = Value::from(323); + mutations.push(( + "non-string sanitizedSourcePath", + success_scenario.to_owned(), + non_string_source_path, + success_expected.clone(), + "privacy-safe sanitizedSourcePath", + )); + + type ShapeMutation = (&'static str, fn(&mut Value), &'static str); + let shape_mutations: [ShapeMutation; 13] = [ + ( + "stateChain object", + |expected: &mut Value| expected["stateChain"] = serde_json::json!({}), + "stateChain must be an array", + ), + ( + "transactions object", + |expected: &mut Value| expected["transactions"] = serde_json::json!({}), + "transactions must be an array", + ), + ( + "transactions empty", + |expected: &mut Value| expected["transactions"] = serde_json::json!([]), + "primary subject is missing", + ), + ( + "findings object", + |expected: &mut Value| expected["findings"] = serde_json::json!({}), + "findings must be an array", + ), + ( + "coverage object", + |expected: &mut Value| expected["coverage"] = serde_json::json!({}), + "coverage must be an array", + ), + ( + "validated families object", + |expected: &mut Value| { + expected["extractionProfile"]["validatedArtifactFamilies"] = serde_json::json!({}) + }, + "validatedArtifactFamilies must be an array", + ), + ( + "counterpartReadyFacts object", + |expected: &mut Value| { + expected["correlationHandoff"]["counterpartReadyFacts"] = serde_json::json!({}) + }, + "counterpartReadyFacts must be an array", + ), + ( + "counterpartReadyFacts scalar", + |expected: &mut Value| { + expected["correlationHandoff"]["counterpartReadyFacts"] = + serde_json::json!("no-facts") + }, + "counterpartReadyFacts must be an array", + ), + ( + "correlationHandoff scalar", + |expected: &mut Value| expected["correlationHandoff"] = serde_json::json!("no-handoff"), + "counterpartReadyFacts must be an array", + ), + ( + "non-string transactionId", + |expected: &mut Value| { + expected["transactions"][0]["transactionId"] = serde_json::json!(323) + }, + "transactionId must be a string", + ), + ( + "non-string transaction state", + |expected: &mut Value| expected["transactions"][0]["state"] = serde_json::json!(false), + "primary subject outcome drifted", + ), + ( + "non-array prohibitedClaims", + |expected: &mut Value| expected["prohibitedClaims"] = serde_json::json!("no claims"), + "prohibitedClaims must be an array", + ), + ( + "string coverageGapArtifactIds", + |expected: &mut Value| { + expected["transactions"][0]["coverageGapArtifactIds"] = + serde_json::json!("client-updates") + }, + "coverageGapArtifactIds must be an array", + ), + ]; + for (label, mutate, marker) in shape_mutations { + let mut expected = success_expected.clone(); + mutate(&mut expected); + mutations.push(( + label, + success_scenario.to_owned(), + success_manifest.clone(), + expected, + marker, + )); + } + + let invalid_offset_scenario = "invalid-offset"; + let invalid_offset_dir = updates_root().join(invalid_offset_scenario); + let invalid_offset_manifest = read_json(&invalid_offset_dir.join("manifest.json")); + let mut missing_invalid_offset_transaction = + read_json(&invalid_offset_dir.join("expected.json")); + missing_invalid_offset_transaction["transactions"] = serde_json::json!([]); + mutations.push(( + "invalid-offset empty transactions", + invalid_offset_scenario.to_owned(), + invalid_offset_manifest, + missing_invalid_offset_transaction, + "invalid-offset: transaction is missing", + )); + + let supplemental_scenario = "supplemental-conflict"; + let supplemental_dir = updates_root().join(supplemental_scenario); + let supplemental_manifest = read_json(&supplemental_dir.join("manifest.json")); + let mut missing_supplemental_observation = read_json(&supplemental_dir.join("expected.json")); + missing_supplemental_observation["sourceLocalObservations"] = serde_json::json!([]); + mutations.push(( + "supplemental-conflict empty observations", + supplemental_scenario.to_owned(), + supplemental_manifest, + missing_supplemental_observation, + "supplemental-conflict: subject is missing", + )); + + let mut non_string_subject_id = read_json(&install_dir.join("expected.json")); + non_string_subject_id["findings"][0]["subjectId"] = serde_json::json!(808); + mutations.push(( + "non-string finding subjectId", + install_scenario.to_owned(), + read_json(&install_dir.join("manifest.json")), + non_string_subject_id, + "subjectId must be a string", + )); + + let mut missing_rejections = Vec::new(); + for (label, scenario, manifest, expected, marker) in mutations { + match validate_without_panicking(&scenario, &manifest, &expected) { + Ok(failures) if failures.iter().any(|failure| failure.contains(marker)) => {} + Ok(failures) => missing_rejections.push(format!( + "{label} in {scenario} (wanted {marker:?}; got {})", + failures.join(" | ") + )), + Err(error) => missing_rejections.push(format!("{label} in {scenario}: {error}")), + } + } + + let malformed_shapes = serde_json::json!({ + "stringField": 323, + "arrayField": "not-an-array", + "mixedArray": ["ok", 323] + }); + type HelperProbe<'probe> = (&'probe str, Box); + let helper_probes: Vec> = vec![ + ( + "json_string on non-string field", + Box::new(|| { + let _ = json_string(&malformed_shapes, "stringField"); + }), + ), + ( + "json_string on missing field", + Box::new(|| { + let _ = json_string(&malformed_shapes, "absentField"); + }), + ), + ( + "string_array on non-array field", + Box::new(|| { + let _ = string_array(&malformed_shapes, "arrayField"); + }), + ), + ( + "string_array on mixed values", + Box::new(|| { + let _ = string_array(&malformed_shapes, "mixedArray"); + }), + ), + ( + "string_array on missing field", + Box::new(|| { + let _ = string_array(&malformed_shapes, "absentField"); + }), + ), + ]; + for (label, probe) in &helper_probes { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(probe)).is_err() { + missing_rejections.push(format!( + "{label}: shape helper panicked on caller-controlled JSON" + )); + } + } + + assert!( + missing_rejections.is_empty(), + "semantic validator accepted or panicked on adversarial input:\n{}", + missing_rejections.join("\n") + ); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs new file mode 100644 index 000000000..e371dd4ba --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -0,0 +1,454 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::{ + SCCM_CORRELATION_IMPLEMENTATION_MODULE, SCCM_CORRELATION_SCHEMA_VERSION, +}; +use serde::Deserialize; +use serde_json::Value; + +const CONTRACT_SCHEMA_VERSION: &str = "1.0.0"; +const WORKFLOWS: [&str; 3] = [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint", +]; +const GUARD_IDS: [&str; 13] = [ + "conflicting-exact-key", + "incompatible-topology", + "invalid-timestamp-offset", + "missing-client-counterpart", + "missing-server-counterpart", + "partial-capture", + "redaction-boundary", + "reordered-input", + "rotation-split", + "same-time-no-key", + "unknown-extraction-profile", + "unrelated-terminal-error", + "version-mismatch", +]; +const MATRIX_PATHS: [(&str, &str, &str); 3] = [ + ( + "content_distribution_point/adversarial-matrix.json", + "contentDistributionPoint", + "content", + ), + ( + "policy_management_point/adversarial-matrix.json", + "policyManagementPoint", + "policy", + ), + ( + "updates_software_update_point/adversarial-matrix.json", + "updatesSoftwareUpdatePoint", + "updates", + ), +]; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PairRegistry { + schema_version: String, + pairs: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PairContract { + pair_id: String, + workflow: String, + client_issue: String, + server_issue: String, + state: String, + production_enabled: bool, + rule_validated: bool, + implementation_module: String, + required_guard_ids: Vec, + blockers: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct GuardMatrix { + schema_version: String, + guards: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct GuardContract { + guard_id: String, + applies_to: Vec, + forbidden_strengths: Vec, + forbidden_confidences: Vec, + required_outputs: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OracleMatrix { + schema_version: String, + pair: String, + scenarios: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OracleScenario { + scenario_id: String, + mutation: String, + expected_outcome: String, + expected_link_strength: String, + expected_confidence: String, + expected_reason_codes: Vec, + expected_triggered_guards: Vec, + expected_output_sha256: String, +} + +fn corpus_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/correlation") +} + +fn read_json(path: &Path) -> Value { + serde_json::from_slice( + &fs::read(path).unwrap_or_else(|error| panic!("{} is readable: {error}", path.display())), + ) + .unwrap_or_else(|error| panic!("{} is JSON: {error}", path.display())) +} + +fn typed Deserialize<'de>>(value: Value) -> Result { + serde_json::from_value(value) + .map_err(|error| format!("fixture is not typed contract JSON: {error}")) +} + +fn sorted_unique(values: &[String]) -> bool { + values.windows(2).all(|pair| pair[0] < pair[1]) +} + +fn valid_issue(value: &str) -> bool { + value.strip_prefix('#').is_some_and(|digits| { + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn valid_hash(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn check_registry(registry: &PairRegistry) -> Result<(), String> { + if registry.schema_version != CONTRACT_SCHEMA_VERSION { + return Err("registry schema version changed".to_owned()); + } + let expected = [ + ( + "content-distribution-point", + "contentDistributionPoint", + "#322", + "#329", + ), + ( + "policy-management-point", + "policyManagementPoint", + "#321", + "#328", + ), + ( + "updates-software-update-point", + "updatesSoftwareUpdatePoint", + "#323", + "#330", + ), + ]; + if registry.pairs.len() != expected.len() { + return Err("registry must contain exactly three pairs".to_owned()); + } + for (pair, (pair_id, workflow, client_issue, server_issue)) in + registry.pairs.iter().zip(expected) + { + if ( + pair.pair_id.as_str(), + pair.workflow.as_str(), + pair.client_issue.as_str(), + pair.server_issue.as_str(), + ) != (pair_id, workflow, client_issue, server_issue) + { + return Err(format!("{}: ownership changed", pair.pair_id)); + } + if !valid_issue(&pair.client_issue) || !valid_issue(&pair.server_issue) { + return Err(format!("{}: malformed issue ownership", pair.pair_id)); + } + if pair.state != "ruleValidated" + || !pair.production_enabled + || !pair.rule_validated + || pair.implementation_module != SCCM_CORRELATION_IMPLEMENTATION_MODULE + || !pair.blockers.is_empty() + { + return Err(format!("{}: pair is not production admitted", pair.pair_id)); + } + if pair.required_guard_ids != GUARD_IDS { + return Err(format!("{}: guard registry changed", pair.pair_id)); + } + } + Ok(()) +} + +fn check_guard_matrix(matrix: &GuardMatrix) -> Result<(), String> { + if matrix.schema_version != CONTRACT_SCHEMA_VERSION { + return Err("guard schema version changed".to_owned()); + } + if matrix + .guards + .iter() + .map(|guard| guard.guard_id.as_str()) + .collect::>() + != GUARD_IDS + { + return Err("shared guard membership changed".to_owned()); + } + for guard in &matrix.guards { + if guard.applies_to != WORKFLOWS { + return Err(format!( + "{}: not applied to all three pairs", + guard.guard_id + )); + } + let invariant_guard = matches!( + guard.guard_id.as_str(), + "redaction-boundary" | "reordered-input" + ); + let forbidden_contract = if invariant_guard { + guard.forbidden_strengths.is_empty() && guard.forbidden_confidences.is_empty() + } else { + guard.forbidden_strengths == ["exactCorroborated"] + && guard.forbidden_confidences == ["high"] + }; + if !forbidden_contract + || guard.required_outputs.is_empty() + || !sorted_unique(&guard.required_outputs) + { + return Err(format!("{}: guard obligations changed", guard.guard_id)); + } + } + Ok(()) +} + +fn mutation_guard(mutation: &str) -> Option<&'static str> { + match mutation { + "conflictingExactKey" => Some("conflicting-exact-key"), + "incompatibleTopology" => Some("incompatible-topology"), + "invalidTimestampOffset" => Some("invalid-timestamp-offset"), + "missingClientCounterpart" => Some("missing-client-counterpart"), + "missingServerCounterpart" => Some("missing-server-counterpart"), + "partialCapture" => Some("partial-capture"), + "redactionBoundary" => Some("redaction-boundary"), + "reorderedInputA" | "reorderedInputB" => Some("reordered-input"), + "rotationSplit" => Some("rotation-split"), + "sameTimeNoKey" => Some("same-time-no-key"), + "unknownExtractionProfile" => Some("unknown-extraction-profile"), + "unrelatedTerminalError" => Some("unrelated-terminal-error"), + "versionMismatch" => Some("version-mismatch"), + "healthy" => None, + other => panic!("unknown executable oracle mutation {other}"), + } +} + +fn check_matrix(matrix: &OracleMatrix, workflow: &str, prefix: &str) -> Result<(), String> { + if matrix.schema_version != CONTRACT_SCHEMA_VERSION || matrix.pair != workflow { + return Err(format!("{workflow}: matrix identity changed")); + } + if matrix.scenarios.len() != 15 { + return Err(format!( + "{workflow}: expected healthy plus 14 adversarial cases" + )); + } + let mut scenario_ids = BTreeSet::new(); + let mut guards = BTreeSet::new(); + let mut reordered_hashes = Vec::new(); + for scenario in &matrix.scenarios { + if !scenario.scenario_id.starts_with(&format!("{prefix}-")) + || !scenario_ids.insert(scenario.scenario_id.as_str()) + { + return Err(format!( + "{}: malformed or duplicate scenario ID", + scenario.scenario_id + )); + } + if !valid_hash(&scenario.expected_output_sha256) { + return Err(format!( + "{}: output hash is not exact SHA-256", + scenario.scenario_id + )); + } + if !sorted_unique(&scenario.expected_reason_codes) + || !sorted_unique(&scenario.expected_triggered_guards) + { + return Err(format!( + "{}: expected lists are not deterministic", + scenario.scenario_id + )); + } + if ![ + "causalFinding", + "candidateOnly", + "counterpartRequested", + "coverageGap", + "incompatible", + "notCausal", + "profileGap", + ] + .contains(&scenario.expected_outcome.as_str()) + || ![ + "exactCorroborated", + "exactPartial", + "candidate", + "incompatible", + "unlinked", + ] + .contains(&scenario.expected_link_strength.as_str()) + || !["low", "medium", "high"].contains(&scenario.expected_confidence.as_str()) + { + return Err(format!( + "{}: expected output vocabulary changed", + scenario.scenario_id + )); + } + if let Some(guard) = mutation_guard(&scenario.mutation) { + guards.insert(guard); + } + if scenario.mutation.starts_with("reorderedInput") { + reordered_hashes.push(scenario.expected_output_sha256.as_str()); + } + if scenario.expected_link_strength == "exactCorroborated" + && scenario.expected_confidence != "high" + { + return Err(format!( + "{}: exact link is not high confidence", + scenario.scenario_id + )); + } + if scenario.expected_confidence == "high" + && scenario.expected_link_strength != "exactCorroborated" + { + return Err(format!( + "{}: high confidence escaped exact linking", + scenario.scenario_id + )); + } + } + if guards != GUARD_IDS.into_iter().collect() { + return Err(format!( + "{workflow}: not every shared guard has an executable scenario" + )); + } + if reordered_hashes.len() != 2 || reordered_hashes[0] != reordered_hashes[1] { + return Err(format!( + "{workflow}: reordered inputs do not pin identical full output" + )); + } + Ok(()) +} + +fn check_mutated_registry(mutate: impl FnOnce(&mut Value)) -> Result<(), String> { + let mut value = read_json(&corpus_root().join("pair-registry.json")); + mutate(&mut value); + check_registry(&typed(value)?) +} + +fn check_mutated_matrix( + path: &str, + workflow: &str, + prefix: &str, + mutate: impl FnOnce(&mut Value), +) -> Result<(), String> { + let mut value = read_json(&corpus_root().join(path)); + mutate(&mut value); + check_matrix(&typed(value)?, workflow, prefix) +} + +#[test] +fn public_module_and_pair_registry_are_production_exact() { + assert_eq!(SCCM_CORRELATION_SCHEMA_VERSION, 1); + assert_eq!(SCCM_CORRELATION_IMPLEMENTATION_MODULE, "sccm::correlation"); + let registry: PairRegistry = + typed(read_json(&corpus_root().join("pair-registry.json"))).expect("typed registry"); + check_registry(®istry).unwrap_or_else(|error| panic!("{error}")); +} + +#[test] +fn all_shared_guards_apply_to_all_three_pairs() { + let matrix: GuardMatrix = typed(read_json( + &corpus_root().join("shared/adversarial-matrix.json"), + )) + .expect("typed guard matrix"); + check_guard_matrix(&matrix).unwrap_or_else(|error| panic!("{error}")); +} + +#[test] +fn all_three_pair_matrices_are_exact_executable_oracles() { + for (path, workflow, prefix) in MATRIX_PATHS { + let matrix: OracleMatrix = + typed(read_json(&corpus_root().join(path))).expect("typed pair matrix"); + check_matrix(&matrix, workflow, prefix).unwrap_or_else(|error| panic!("{error}")); + } +} + +#[test] +fn production_registry_mutations_fail_closed() { + let error = check_mutated_registry(|registry| { + registry["pairs"][0]["productionEnabled"] = Value::Bool(false); + }) + .expect_err("production disablement cannot remain admitted"); + assert!(error.contains("production admitted"), "{error}"); + + let error = check_mutated_registry(|registry| { + registry["pairs"][1]["implementationModule"] = Value::String("sccm::other".to_owned()); + }) + .expect_err("module ownership cannot drift"); + assert!(error.contains("production admitted"), "{error}"); + + let error = check_mutated_registry(|registry| { + registry["pairs"][2]["requiredGuardIds"] + .as_array_mut() + .expect("guard list") + .pop(); + }) + .expect_err("no pair may omit a shared guard"); + assert!(error.contains("guard registry"), "{error}"); +} + +#[test] +fn exact_oracle_hash_and_reordering_mutations_fail_closed() { + let (path, workflow, prefix) = MATRIX_PATHS[0]; + let error = check_mutated_matrix(path, workflow, prefix, |matrix| { + matrix["scenarios"][0]["expectedOutputSha256"] = Value::String("not-a-hash".to_owned()); + }) + .expect_err("malformed full-output hash cannot pass"); + assert!(error.contains("SHA-256"), "{error}"); + + let error = check_mutated_matrix(path, workflow, prefix, |matrix| { + let first = matrix["scenarios"][8]["expectedOutputSha256"].clone(); + matrix["scenarios"][9]["expectedOutputSha256"] = Value::String(format!( + "{}0", + first.as_str().expect("hash").trim_end_matches('0') + )); + }) + .expect_err("opposite orders must retain the same full-output hash"); + assert!(error.contains("reordered"), "{error}"); +} + +#[test] +fn guard_matrix_scope_mutation_fails_closed() { + let mut value = read_json(&corpus_root().join("shared/adversarial-matrix.json")); + value["guards"][0]["appliesTo"] + .as_array_mut() + .expect("appliesTo") + .pop(); + let matrix: GuardMatrix = typed(value).expect("typed mutated guard matrix"); + let error = check_guard_matrix(&matrix).expect_err("two-pair scope cannot pass"); + assert!(error.contains("all three pairs"), "{error}"); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs new file mode 100644 index 000000000..3371e31dd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs @@ -0,0 +1,1047 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::models::log_entry::Severity; +use cmtraceopen_parser::sccm::server::windows::{ + analyze_hierarchy_replication, assess_server_intake, declared_server_source_catalog, + SccmHierarchyDirection, SccmHierarchyProfileSelectionState, SccmHierarchyRemoteCausality, + SccmHierarchyState, SccmHierarchyTimestampOrdering, SccmServerArtifactPayload, + SccmServerIntakeAssessment, SCCM_HIERARCHY_PROFILE_ID, SCCM_HIERARCHY_SOURCE_VERSION, +}; +use cmtraceopen_parser::sccm::{ + SccmConfidence, SccmCorrelationKeyKind, SccmFindingClass, SccmKeyConfidence, SccmRole, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +const SCENARIOS: &[&str] = &[ + "absent-remote-source", + "backlog-retry", + "clock-offset-unknown", + "generic-site-token", + "healthy-link", + "incomplete", + "receiver-processing-failure", + "recovery", + "rotation-boundary", + "sender-failure", + "topology-mismatch", +]; + +const FULL_OUTPUT_SHA256: &[(&str, &str)] = &[ + ( + "absent-remote-source", + "d433de24f68f9675b97afb3a65b6fe664f389dd3dafb780d5eaae1284f423153", + ), + ( + "backlog-retry", + "e63b67202b6e5a0aa9179000ad72d3215cae3d5b8bfc894fea2f8c9c7bde36a9", + ), + ( + "clock-offset-unknown", + "da02ddec04085e5c729365212b2c741d92fc1ec025bfe7bb01ed481e93f6fa71", + ), + ( + "generic-site-token", + "03b82f7f80db1f0fe6a6a3738cef962545584d280002558468d03c67e290dcb5", + ), + ( + "healthy-link", + "cd3a558339e006cbd8003bc35c81dc94b6db90391302863772debe7e17cff39f", + ), + ( + "incomplete", + "43f1a50ac2818110176cb8e4db007e1cbbf3ef920665c956bf8452f80263d9a9", + ), + ( + "receiver-processing-failure", + "5785052b51919023838db1bb0d282e234002f9eb05609dbf8d74e188e0f04fd7", + ), + ( + "recovery", + "d2d7035ae31dc35ed07b517bc8ba1f08159460316fba38d8267932067b7969d9", + ), + ( + "rotation-boundary", + "452c1efab5b79c76fb496e75d4ffa8716ed1f41afe47c13709872f4448eb5d42", + ), + ( + "sender-failure", + "87934ae0c81f5bde7d075fc11f75d982f2f81c02dd045df52ff8c24b51bc88c3", + ), + ( + "topology-mismatch", + "6747a6b9e63c88ff544be788bf0ce1c51dfa2dfb04dedf45cc67dcb76efc6304", + ), +]; + +fn corpus_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/hierarchy_and_replication") +} + +fn fixture_parts(scenario: &str) -> (Value, Vec, Value) { + let root = corpus_root().join(scenario); + let source: Value = serde_json::from_str( + &std::fs::read_to_string(root.join("manifest.json")).expect("manifest is readable"), + ) + .expect("manifest is valid JSON"); + let expected: Value = serde_json::from_str( + &std::fs::read_to_string(root.join("expected.json")).expect("expected is readable"), + ) + .expect("expected is valid JSON"); + + let topology = &source["topology"]; + let mut links = vec![json!({ + "originSiteCode": required_str(topology, "originSiteCode"), + "targetSiteCode": required_str(topology, "targetSiteCode"), + "originHostHandle": canonical_host(required_str(topology, "originHostHandle")), + "targetHostHandle": canonical_host(required_str(topology, "targetHostHandle")), + })]; + links.extend( + topology["additionalTargets"] + .as_array() + .into_iter() + .flatten() + .map(|target| { + json!({ + "originSiteCode": required_str(topology, "originSiteCode"), + "targetSiteCode": required_str(target, "siteCode"), + "originHostHandle": canonical_host(required_str(topology, "originHostHandle")), + "targetHostHandle": canonical_host(required_str(target, "hostHandle")), + }) + }), + ); + + let mut payloads = Vec::new(); + let artifacts = source["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .map(|artifact| { + let artifact_id = required_str(artifact, "artifactId"); + let source_id = required_str(artifact, "sourceId"); + let basename = required_str(artifact, "originalBasename"); + let rotation = match required_str(&artifact["rotation"], "kind") { + "current" => "current", + "loUnderscore" => "lo_", + value => panic!("unsupported hierarchy rotation {value}"), + }; + let physical = matches!( + required_str(artifact, "captureState"), + "captured" | "capped" | "parseFailed" + ); + let mut normalized = json!({ + "artifactId": artifact_id, + "producerRole": "siteServer", + "producerHostHandle": canonical_host(required_str(artifact, "producerHostHandle")), + "sourceId": source_id, + "sourceKind": "ccmLog", + "sourceVersion": SCCM_HIERARCHY_SOURCE_VERSION, + "originalPath": "REDACTED_HIERARCHY", + "originalBasename": basename, + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": required_str(artifact, "pathFingerprint"), + }, + "rotation": { + "kind": rotation, + "lineageId": required_str(&artifact["rotation"], "lineageId"), + }, + "captureState": artifact["captureState"], + "collectedUtc": artifact["collectedUtc"], + "bytesCopied": 0, + }); + if physical { + let old_relative_path = required_str(artifact, "relativePath"); + let bytes = std::fs::read(root.join(old_relative_path)) + .expect("hierarchy payload is readable"); + normalized["encoding"] = json!("utf-8"); + normalized["collectionLimit"] = artifact["collectionLimit"].clone(); + normalized["bytesCopied"] = json!(bytes.len()); + normalized["relativePath"] = json!(format!( + "evidence/sccm/server/site-server/{source_id}/{rotation}/{basename}" + )); + if artifact["captureState"] == "capped" { + normalized["truncated"] = json!(true); + normalized["fragmentComplete"] = json!(false); + } + payloads.push(SccmServerArtifactPayload { + manifest_artifact_id: artifact_id.to_owned(), + bytes, + }); + } + normalized + }) + .collect::>(); + + ( + json!({ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": {"synthetic": true, "rawPaths": "redacted"}, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": ["siteServer"], + "hierarchyLinks": links, + }, + "artifacts": artifacts, + }), + payloads, + expected, + ) +} + +fn assess(manifest: &Value, payloads: &[SccmServerArtifactPayload]) -> SccmServerIntakeAssessment { + assess_server_intake( + &serde_json::to_string(manifest).expect("manifest serializes"), + payloads, + ) + .expect("canonical hierarchy intake is admitted") +} + +fn load_assessment(scenario: &str) -> (SccmServerIntakeAssessment, Value) { + let (manifest, payloads, expected) = fixture_parts(scenario); + (assess(&manifest, &payloads), expected) +} + +fn payload_mut<'a>( + payloads: &'a mut [SccmServerArtifactPayload], + artifact_id: &str, +) -> &'a mut Vec { + &mut payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == artifact_id) + .expect("payload exists") + .bytes +} + +fn sync_payload_length(manifest: &mut Value, payloads: &[SccmServerArtifactPayload]) { + for artifact in manifest["artifacts"] + .as_array_mut() + .expect("artifacts are mutable") + { + if let Some(payload) = payloads + .iter() + .find(|payload| payload.manifest_artifact_id == artifact["artifactId"]) + { + artifact["bytesCopied"] = json!(payload.bytes.len()); + } + } +} + +fn required_str<'a>(value: &'a Value, field: &str) -> &'a str { + value[field] + .as_str() + .unwrap_or_else(|| panic!("{field} is a string")) +} + +fn canonical_host(value: &str) -> &'static str { + match value { + "safe:server:lab-pri-01" => "synthetic:host:site-01", + "safe:server:lab-chd-01" => "synthetic:host:site-02", + "safe:server:lab-sec-01" => "synthetic:host:site-03", + value => panic!("unregistered hierarchy fixture host {value}"), + } +} + +#[test] +fn every_corpus_scenario_runs_through_canonical_intake_and_the_exported_analyzer() { + for scenario in SCENARIOS { + let (intake, expected) = load_assessment(scenario); + let analysis = analyze_hierarchy_replication(&intake).expect("sealed intake is trusted"); + assert_eq!(analysis.workflow, "hierarchyAndReplication", "{scenario}"); + assert_eq!(analysis.state_chain.len(), 7, "{scenario}"); + assert_eq!( + analysis.extraction_profile.selection_state, + SccmHierarchyProfileSelectionState::SelectedSynthetic, + "{scenario}" + ); + assert_eq!( + analysis.extraction_profile.profile_id.as_deref(), + Some(SCCM_HIERARCHY_PROFILE_ID), + "{scenario}" + ); + assert!( + analysis.extraction_profile.synthetic_fixture_only, + "{scenario}" + ); + assert!(!analysis.native_validation_performed, "{scenario}"); + assert!(analysis.cross_side_causal_claims.is_empty(), "{scenario}"); + assert_eq!( + coverage_projection(&analysis), + coverage_expectation(&expected, scenario), + "{scenario}" + ); + assert_eq!( + transaction_projection(&analysis), + transaction_expectation(&expected), + "{scenario}" + ); + assert_eq!( + source_local_projection(&analysis), + source_local_expectation(&expected), + "{scenario}" + ); + assert_eq!( + request_projection(&analysis), + expected["artifactRequests"], + "{scenario}" + ); + assert!(analysis + .findings + .iter() + .all(|finding| finding.validate().is_ok())); + + let mut ids = BTreeSet::new(); + for observation in analysis + .transactions + .iter() + .flat_map(|transaction| transaction.observations.iter()) + { + assert!(ids.insert(observation.observation_id.clone()), "{scenario}"); + let reference = &observation.evidence[0]; + assert!(observation.observation_id.contains(&reference.artifact_id)); + assert!(observation + .observation_id + .contains(&reference.line_start.expect("line").to_string())); + } + + let (mut reversed_manifest, mut reversed_payloads, _) = fixture_parts(scenario); + reversed_manifest["artifacts"] + .as_array_mut() + .expect("artifacts") + .reverse(); + reversed_payloads.reverse(); + let reversed = + analyze_hierarchy_replication(&assess(&reversed_manifest, &reversed_payloads)) + .expect("reordered intake is trusted"); + assert_eq!( + serde_json::to_value(&analysis).expect("analysis serializes"), + serde_json::to_value(reversed).expect("reversed analysis serializes"), + "input order changed full output for {scenario}" + ); + + if let Some((_, expected_digest)) = + FULL_OUTPUT_SHA256.iter().find(|(name, _)| name == scenario) + { + assert_eq!( + full_output_digest(&analysis), + *expected_digest, + "{scenario}" + ); + } else { + eprintln!("{scenario} {}", full_output_digest(&analysis)); + } + } + assert_eq!(FULL_OUTPUT_SHA256.len(), SCENARIOS.len()); +} + +#[test] +fn hierarchy_authority_is_private_canonical_intake_and_all_provenance_is_sealed() { + let (intake, _) = load_assessment("healthy-link"); + for mutate in [ + |assessment: &mut SccmServerIntakeAssessment| { + assessment.evidence[0].message.push_str(" mutated") + }, + |assessment: &mut SccmServerIntakeAssessment| { + assessment.artifacts[0].content_sha256 = Some("0".repeat(64)) + }, + |assessment: &mut SccmServerIntakeAssessment| { + assessment.artifacts[0].source_version = Some("5.00.9128.1007".to_owned()) + }, + |assessment: &mut SccmServerIntakeAssessment| { + assessment.artifacts[0].collected_at_utc = "2026-07-30T20:00:00Z".to_owned() + }, + |assessment: &mut SccmServerIntakeAssessment| { + assessment.artifacts[0] + .capture_provenance + .as_mut() + .expect("provenance") + .encoding = "windows-1252".to_owned() + }, + |assessment: &mut SccmServerIntakeAssessment| { + assessment.artifacts[0].producer_host_handle = Some("synthetic:host:site-03".to_owned()) + }, + ] { + let mut changed = intake.clone(); + mutate(&mut changed); + assert_eq!( + analyze_hierarchy_replication(&changed), + Err(cmtraceopen_parser::sccm::server::windows::SccmHierarchyError::UntrustedIntake) + ); + } + + let (mut manifest, mut payloads, _) = fixture_parts("healthy-link"); + let arbitrary = String::from_utf8(payload_mut(&mut payloads, "healthy-01-replmgr").clone()) + .expect("utf8") + .replace("msg-healthy-01", "msg-unreviewed-01"); + *payload_mut(&mut payloads, "healthy-01-replmgr") = arbitrary.into_bytes(); + sync_payload_length(&mut manifest, &payloads); + let arbitrary = analyze_hierarchy_replication(&assess(&manifest, &payloads)) + .expect("canonical intake remains well formed"); + assert_eq!( + arbitrary.extraction_profile.selection_state, + SccmHierarchyProfileSelectionState::Unavailable + ); + assert!(arbitrary.transactions.is_empty()); +} + +#[test] +fn registered_hierarchy_profile_bounds_failures_without_required_target_sources() { + let (intake, _) = load_assessment("sender-failure"); + let analysis = analyze_hierarchy_replication(&intake).expect("sealed intake"); + assert_eq!(analysis.transactions.len(), 2); + for transaction in &analysis.transactions { + assert!(transaction.correlation_keys.iter().any(|key| { + key.kind == SccmCorrelationKeyKind::HierarchyMessageId + && key.confidence == SccmKeyConfidence::Exact + && key.extraction_profile_id.as_deref() == Some(SCCM_HIERARCHY_PROFILE_ID) + })); + assert!(transaction.correlation_keys.iter().any(|key| { + key.kind == SccmCorrelationKeyKind::HierarchyLinkId + && key.confidence == SccmKeyConfidence::Exact + })); + assert_eq!(transaction.state, SccmHierarchyState::Incomplete); + assert_eq!(transaction.confidence, SccmConfidence::Low); + assert_eq!( + transaction.remote_causality, + SccmHierarchyRemoteCausality::NotEstablished + ); + assert_eq!(transaction.next_artifacts.len(), 2); + } + assert_eq!(analysis.findings.len(), 2); + assert!(analysis.findings.iter().all(|finding| { + finding.class == SccmFindingClass::Symptom + && finding.severity == Severity::Warning + && finding.terminal_evidence.is_empty() + && finding.validate().is_ok() + })); +} + +#[test] +fn equal_time_ordering_uses_physical_lines_and_cross_artifact_ties_fail_closed() { + let (mut manifest, mut payloads, _) = fixture_parts("healthy-link"); + let replmgr = String::from_utf8(payload_mut(&mut payloads, "healthy-01-replmgr").clone()) + .expect("utf8") + .replace("15:03:01.000+000", "15:03:00.000+000"); + *payload_mut(&mut payloads, "healthy-01-replmgr") = replmgr.clone().into_bytes(); + sync_payload_length(&mut manifest, &payloads); + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed"); + assert_eq!( + analysis.transactions[0].timestamp_ordering, + SccmHierarchyTimestampOrdering::Usable + ); + + let physically_reversed = replmgr + .replace("Phase=initiate", "Phase=TEMP") + .replace("Phase=queueOrSerialize", "Phase=initiate") + .replace("Phase=TEMP", "Phase=queueOrSerialize"); + *payload_mut(&mut payloads, "healthy-01-replmgr") = physically_reversed.into_bytes(); + sync_payload_length(&mut manifest, &payloads); + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed"); + assert_eq!( + analysis.transactions[0].timestamp_ordering, + SccmHierarchyTimestampOrdering::Contradictory + ); + + let (mut manifest, mut payloads, _) = fixture_parts("healthy-link"); + let sender = String::from_utf8(payload_mut(&mut payloads, "healthy-02-sender").clone()) + .expect("utf8") + .replace("15:03:02.000+000", "15:03:01.000+000"); + *payload_mut(&mut payloads, "healthy-02-sender") = sender.into_bytes(); + sync_payload_length(&mut manifest, &payloads); + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed"); + assert_eq!( + analysis.transactions[0].timestamp_ordering, + SccmHierarchyTimestampOrdering::Contradictory + ); + assert_eq!( + analysis.transactions[0].state, + SccmHierarchyState::Contradictory + ); +} + +#[test] +fn rotation_split_requires_the_exact_current_lo_lineage_and_topology_pair() { + let (mut manifest, payloads, _) = fixture_parts("rotation-boundary"); + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed"); + assert_eq!( + analysis.source_local_observations.len(), + 1, + "{:?}", + analysis.source_local_observations + ); + assert_eq!( + analysis.source_local_observations[0].reason_code, + "rotationSplit" + ); + assert_eq!(analysis.artifact_requests[0].transaction_id, None); + assert_eq!(analysis.artifact_requests[0].origin_site_code, "LAB"); + assert_eq!(analysis.artifact_requests[0].target_site_code, "CHD"); + + manifest["artifacts"][1]["rotation"]["lineageId"] = json!("healthy-sender"); + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed"); + assert!(analysis + .source_local_observations + .iter() + .all(|observation| observation.reason_code != "rotationSplit")); +} + +#[test] +fn gaps_and_requests_are_transaction_and_exact_topology_scoped() { + let (intake, _) = load_assessment("absent-remote-source"); + let analysis = analyze_hierarchy_replication(&intake).expect("sealed"); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!( + analysis.transactions[0].coverage_gap_artifact_ids, + ["absent-02-despool"] + ); + assert!(analysis.transactions[0] + .next_artifacts + .iter() + .all(|request| { + request.transaction_id.as_deref() + == Some(analysis.transactions[0].transaction_id.as_str()) + && request.origin_site_code == "LAB" + && request.target_site_code == "CHD" + })); + assert!(analysis.artifact_requests.iter().all(|request| { + request.transaction_id.as_deref() == Some(analysis.transactions[0].transaction_id.as_str()) + && request.origin_site_code == "LAB" + && request.target_site_code == "CHD" + })); +} + +fn assert_missing_target_sources(missing: &[(&str, &str)]) { + let (mut manifest, mut payloads, _) = fixture_parts("healthy-link"); + remove_target_sources(&mut manifest, &mut payloads, missing); + + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed"); + assert_missing_target_gate(&analysis, missing); +} + +fn remove_target_sources( + manifest: &mut Value, + payloads: &mut Vec, + missing: &[(&str, &str)], +) { + let missing_basenames = missing + .iter() + .map(|(_, basename)| *basename) + .collect::>(); + let target_ids = manifest["artifacts"] + .as_array() + .expect("artifacts") + .iter() + .filter(|artifact| missing_basenames.contains(required_str(artifact, "originalBasename"))) + .map(|artifact| required_str(artifact, "artifactId").to_owned()) + .collect::>(); + manifest["artifacts"] + .as_array_mut() + .expect("artifacts") + .retain(|artifact| !target_ids.contains(required_str(artifact, "artifactId"))); + payloads.retain(|payload| !target_ids.contains(&payload.manifest_artifact_id)); +} + +fn declare_target_source_state( + manifest: &mut Value, + payloads: &mut Vec, + basename: &str, + state: &str, +) { + let artifact = manifest["artifacts"] + .as_array_mut() + .expect("artifacts") + .iter_mut() + .find(|artifact| required_str(artifact, "originalBasename") == basename) + .expect("target artifact"); + let artifact_id = required_str(artifact, "artifactId").to_owned(); + artifact["captureState"] = json!(state); + + match state { + "capped" => { + let payload_len = payloads + .iter() + .find(|payload| payload.manifest_artifact_id == artifact_id) + .expect("capped payload") + .bytes + .len(); + artifact["collectionLimit"]["byteLimit"] = json!(payload_len); + artifact["collectionLimit"]["limitApplied"] = json!(true); + artifact["bytesCopied"] = json!(payload_len); + artifact["truncated"] = json!(true); + artifact["fragmentComplete"] = json!(false); + } + "parseFailed" => {} + "absent" | "accessDenied" | "skipped" | "unsupported" => { + let object = artifact.as_object_mut().expect("artifact object"); + for field in [ + "encoding", + "collectionLimit", + "relativePath", + "truncated", + "fragmentComplete", + ] { + object.remove(field); + } + artifact["bytesCopied"] = json!(0); + payloads.retain(|payload| payload.manifest_artifact_id != artifact_id); + } + value => panic!("unsupported test coverage state {value}"), + } +} + +fn tied_healthy_fixture() -> (Value, Vec) { + let (mut manifest, mut payloads, _) = fixture_parts("healthy-link"); + let sender = String::from_utf8(payload_mut(&mut payloads, "healthy-02-sender").clone()) + .expect("utf8") + .replace("15:03:02.000+000", "15:03:01.000+000"); + *payload_mut(&mut payloads, "healthy-02-sender") = sender.into_bytes(); + sync_payload_length(&mut manifest, &payloads); + (manifest, payloads) +} + +fn add_target_sources_from_healthy( + manifest: &mut Value, + payloads: &mut Vec, + basenames: &[&str], +) { + let (healthy_manifest, healthy_payloads, _) = fixture_parts("healthy-link"); + for basename in basenames { + let artifact = healthy_manifest["artifacts"] + .as_array() + .expect("healthy artifacts") + .iter() + .find(|artifact| required_str(artifact, "originalBasename") == *basename) + .expect("registered healthy target artifact") + .clone(); + let artifact_id = required_str(&artifact, "artifactId").to_owned(); + manifest["artifacts"] + .as_array_mut() + .expect("artifacts") + .push(artifact); + payloads.push( + healthy_payloads + .iter() + .find(|payload| payload.manifest_artifact_id == artifact_id) + .expect("registered healthy target payload") + .clone(), + ); + } +} + +fn assert_missing_target_gate( + analysis: &cmtraceopen_parser::sccm::server::windows::SccmHierarchyAnalysis, + missing: &[(&str, &str)], +) { + assert_eq!(analysis.transactions.len(), 1); + let transaction = &analysis.transactions[0]; + assert_eq!(transaction.state, SccmHierarchyState::Incomplete); + assert_eq!(transaction.confidence, SccmConfidence::Low); + assert_eq!( + transaction.remote_causality, + SccmHierarchyRemoteCausality::NotEstablished + ); + + assert_eq!(transaction.next_artifacts.len(), missing.len()); + assert!(transaction.next_artifacts.iter().all(|request| { + request.transaction_id.as_deref() == Some(transaction.transaction_id.as_str()) + && request.direction == SccmHierarchyDirection::Target + && request.origin_site_code == "LAB" + && request.target_site_code == "CHD" + && request.reason_code == "missingTargetReceiveProcessApply" + })); + let requested_sources = transaction + .next_artifacts + .iter() + .map(|request| { + assert_eq!(request.basenames.len(), 1); + (request.source_id.as_str(), request.basenames[0].as_str()) + }) + .collect::>(); + assert_eq!(requested_sources, missing.iter().copied().collect()); + assert_eq!(analysis.artifact_requests, transaction.next_artifacts); +} + +#[test] +fn omitted_target_despool_emits_only_transfer_request() { + assert_missing_target_sources(&[("server-hierarchy-transfer", "despool.log")]); +} + +#[test] +fn omitted_target_rcmctrl_emits_only_control_request() { + assert_missing_target_sources(&[("server-hierarchy-control", "rcmctrl.log")]); +} + +#[test] +fn omitted_both_target_sources_emit_both_requests() { + assert_missing_target_sources(&[ + ("server-hierarchy-transfer", "despool.log"), + ("server-hierarchy-control", "rcmctrl.log"), + ]); +} + +#[test] +fn missing_target_gate_precedes_cross_artifact_contradiction_and_terminal_success() { + let (manifest, payloads) = tied_healthy_fixture(); + + let both_present = + analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed control"); + assert_eq!(both_present.transactions.len(), 1); + assert_eq!( + both_present.transactions[0].timestamp_ordering, + SccmHierarchyTimestampOrdering::Contradictory + ); + assert_eq!( + both_present.transactions[0].state, + SccmHierarchyState::Contradictory + ); + assert!(both_present.transactions[0].terminal_evidence); + + for missing in [ + [("server-hierarchy-transfer", "despool.log")], + [("server-hierarchy-control", "rcmctrl.log")], + ] { + let mut missing_manifest = manifest.clone(); + let mut missing_payloads = payloads.clone(); + remove_target_sources(&mut missing_manifest, &mut missing_payloads, &missing); + let analysis = analyze_hierarchy_replication(&assess(&missing_manifest, &missing_payloads)) + .expect("sealed missing-target case"); + assert_eq!( + analysis.transactions[0].timestamp_ordering, + SccmHierarchyTimestampOrdering::Contradictory + ); + assert_missing_target_gate(&analysis, &missing); + } +} + +#[test] +fn declared_absent_target_sources_gate_equal_time_contradiction() { + for (source_id, basename) in [ + ("server-hierarchy-transfer", "despool.log"), + ("server-hierarchy-control", "rcmctrl.log"), + ] { + let (mut manifest, mut payloads) = tied_healthy_fixture(); + declare_target_source_state(&mut manifest, &mut payloads, basename, "absent"); + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)) + .expect("sealed declared-absent case"); + assert_eq!( + analysis.transactions[0].timestamp_ordering, + SccmHierarchyTimestampOrdering::Contradictory + ); + assert_missing_target_gate(&analysis, &[(source_id, basename)]); + } +} + +#[test] +fn every_other_non_usable_target_state_gates_equal_time_contradiction() { + for state in [ + "accessDenied", + "capped", + "skipped", + "unsupported", + "parseFailed", + ] { + for (source_id, basename) in [ + ("server-hierarchy-transfer", "despool.log"), + ("server-hierarchy-control", "rcmctrl.log"), + ] { + let (mut manifest, mut payloads) = tied_healthy_fixture(); + declare_target_source_state(&mut manifest, &mut payloads, basename, state); + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)) + .unwrap_or_else(|error| panic!("sealed {state} case: {error:?}")); + assert_eq!( + analysis.transactions[0].timestamp_ordering, + SccmHierarchyTimestampOrdering::Contradictory, + "{state}/{basename}" + ); + assert_missing_target_gate(&analysis, &[(source_id, basename)]); + } + } +} + +#[test] +fn both_target_sources_preserve_terminal_retry_and_recovery_selection() { + for (scenario, message_id, added, expected) in [ + ( + "backlog-retry", + "msg-backlog-01", + &["despool.log", "rcmctrl.log"][..], + SccmHierarchyState::Deferred, + ), + ( + "sender-failure", + "msg-send-chd", + &["despool.log", "rcmctrl.log"][..], + SccmHierarchyState::Failed, + ), + ( + "receiver-processing-failure", + "msg-receiver-01", + &["rcmctrl.log"][..], + SccmHierarchyState::Failed, + ), + ( + "recovery", + "msg-recovery-01", + &["rcmctrl.log"][..], + SccmHierarchyState::Recovered, + ), + ] { + let (mut manifest, mut payloads, _) = fixture_parts(scenario); + add_target_sources_from_healthy(&mut manifest, &mut payloads, added); + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)) + .expect("sealed both-present control"); + let transaction = analysis + .transactions + .iter() + .find(|transaction| transaction.key.message_id == message_id) + .expect("control transaction"); + assert_eq!(transaction.state, expected, "{scenario}"); + assert!(transaction + .next_artifacts + .iter() + .all(|request| { request.reason_code != "missingTargetReceiveProcessApply" })); + } + + let (intake, _) = load_assessment("healthy-link"); + let healthy = analyze_hierarchy_replication(&intake).expect("sealed success control"); + assert_eq!(healthy.transactions[0].state, SccmHierarchyState::Succeeded); +} + +#[test] +fn malformed_grammar_is_retained_source_locally_without_promoting_keys() { + let (mut manifest, mut payloads, _) = fixture_parts("sender-failure"); + let malformed = String::from_utf8(payload_mut(&mut payloads, "sender-failure-01-chd").clone()) + .expect("utf8") + .replace("MessageId=msg-send-chd", "MessageId=content-send-chd"); + *payload_mut(&mut payloads, "sender-failure-01-chd") = malformed.into_bytes(); + sync_payload_length(&mut manifest, &payloads); + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed"); + assert_eq!(analysis.transactions.len(), 1); + assert!(analysis + .source_local_observations + .iter() + .any(|observation| { + observation.reason_code == "topologyOrGrammarMismatch" + && observation.observation_id.contains(":1-1:") + })); +} + +#[test] +fn hierarchy_sources_live_in_the_canonical_server_windows_catalog() { + let hierarchy = declared_server_source_catalog() + .iter() + .filter(|spec| spec.source_id.starts_with("server-hierarchy-")) + .collect::>(); + assert_eq!(hierarchy.len(), 2); + assert!(hierarchy.iter().all(|spec| { + spec.producer_role == SccmRole::SiteServer + && spec.workflow_subject_role.is_none() + && !spec.supplemental + })); + assert_eq!(hierarchy[0].logical_names, ["replmgr", "rcmctrl"]); + assert_eq!(hierarchy[1].logical_names, ["sender", "despool"]); +} + +fn full_output_digest( + analysis: &cmtraceopen_parser::sccm::server::windows::SccmHierarchyAnalysis, +) -> String { + let bytes = serde_json::to_vec(analysis).expect("analysis serializes"); + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn coverage_projection( + analysis: &cmtraceopen_parser::sccm::server::windows::SccmHierarchyAnalysis, +) -> Value { + Value::Array( + analysis + .coverage + .iter() + .map(|coverage| { + json!({ + "artifactId": coverage.artifact_id, + "state": serde_json::to_value(coverage.state.clone()).expect("state serializes"), + }) + }) + .collect(), + ) +} + +fn coverage_expectation(expected: &Value, scenario: &str) -> Value { + let mut coverage = expected["coverage"].clone(); + if scenario == "rotation-boundary" { + for item in coverage.as_array_mut().expect("coverage array") { + item["state"] = json!("parseFailed"); + } + } + coverage +} + +fn transaction_projection( + analysis: &cmtraceopen_parser::sccm::server::windows::SccmHierarchyAnalysis, +) -> Value { + Value::Array( + analysis + .transactions + .iter() + .map(|transaction| { + json!({ + "transactionId": transaction.transaction_id, + "key": { + "messageId": transaction.key.message_id, + "linkId": transaction.key.link_id, + "originSiteCode": transaction.key.origin_site_code, + "targetSiteCode": transaction.key.target_site_code, + "confidence": "exact", + "extractionProfileId": "hierarchy-server-5.00.test-v1", + }, + "topologyCompatibility": transaction.topology_compatibility, + "timestampOrdering": transaction.timestamp_ordering, + "terminalEvidence": transaction.terminal_evidence, + "state": transaction.state, + "classification": classification(transaction), + "confidence": confidence_name(transaction.confidence), + "confidenceCeiling": confidence_name(transaction.confidence_ceiling), + "coverageGapArtifactIds": transaction.coverage_gap_artifact_ids, + "observations": transaction.observations.iter().map(|observation| json!({ + "phase": observation.phase, + "disposition": observation.disposition, + "terminal": observation.terminal, + "evidence": observation.evidence.iter().map(|reference| json!({ + "artifactId": reference.artifact_id, + "startLine": reference.line_start, + "endLine": reference.line_end, + })).collect::>(), + })).collect::>(), + }) + }) + .collect(), + ) +} + +fn transaction_expectation(expected: &Value) -> Value { + Value::Array( + expected["transactions"] + .as_array() + .expect("transactions are an array") + .iter() + .map(|transaction| { + json!({ + "transactionId": transaction["transactionId"], + "key": transaction["key"], + "topologyCompatibility": transaction["topologyCompatibility"], + "timestampOrdering": transaction["timestampOrdering"], + "terminalEvidence": transaction["terminalEvidence"], + "state": transaction["state"], + "classification": transaction["classification"], + "confidence": transaction["confidence"], + "confidenceCeiling": transaction["confidenceCeiling"], + "coverageGapArtifactIds": transaction["coverageGapArtifactIds"], + "observations": transaction["observations"].as_array().expect("observations").iter().map(|observation| json!({ + "phase": observation["phase"], + "disposition": observation["disposition"], + "terminal": observation["terminal"], + "evidence": observation["evidence"], + })).collect::>(), + }) + }) + .collect(), + ) +} + +fn classification( + transaction: &cmtraceopen_parser::sccm::server::windows::SccmHierarchyTransaction, +) -> &'static str { + match transaction.state { + SccmHierarchyState::Succeeded | SccmHierarchyState::Recovered => "success", + SccmHierarchyState::Failed => "confirmedFailure", + SccmHierarchyState::Deferred => "blockedOrDeferred", + SccmHierarchyState::Incomplete => "insufficientEvidence", + SccmHierarchyState::Contradictory => "contradictoryEvidence", + } +} + +fn confidence_name(confidence: SccmConfidence) -> &'static str { + match confidence { + SccmConfidence::None => "none", + SccmConfidence::Low => "low", + SccmConfidence::Moderate => "medium", + SccmConfidence::High => "high", + } +} + +fn source_local_projection( + analysis: &cmtraceopen_parser::sccm::server::windows::SccmHierarchyAnalysis, +) -> Value { + Value::Array( + analysis + .source_local_observations + .iter() + .map(|observation| { + json!({ + "classification": match observation.reason_code.as_str() { + "rotationSplit" => "rotationSplit", + "topologyOrGrammarMismatch" | "unlinkedTopologyCandidate" | "topologyMismatch" => "topologyMismatch", + _ => "coverageOnly", + }, + "confidence": confidence_name(observation.confidence), + "correlationEligible": observation.correlation_eligible, + "artifactIds": observation.artifact_ids, + "evidence": observation.evidence.iter().map(|reference| json!({ + "artifactId": reference.artifact_id, + "startLine": reference.line_start, + "endLine": reference.line_end, + })).collect::>(), + }) + }) + .collect(), + ) +} + +fn source_local_expectation(expected: &Value) -> Value { + Value::Array( + expected["sourceLocalObservations"] + .as_array() + .expect("source-local observations are an array") + .iter() + .map(|observation| { + json!({ + "classification": observation["classification"], + "confidence": observation["confidence"], + "correlationEligible": observation["correlationEligible"], + "artifactIds": observation["artifactIds"], + "evidence": observation["evidence"], + }) + }) + .collect(), + ) +} + +fn request_projection( + analysis: &cmtraceopen_parser::sccm::server::windows::SccmHierarchyAnalysis, +) -> Value { + Value::Array( + analysis + .artifact_requests + .iter() + .map(|request| { + json!({ + "sourceId": request.source_id, + "producerRole": request.producer_role, + "direction": request.direction, + "targetSiteCode": request.target_site_code, + "basenames": request.basenames, + "reasonCode": request.reason_code, + }) + }) + .collect(), + ) +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs new file mode 100644 index 000000000..91b8dfbb8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs @@ -0,0 +1,972 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use serde_json::Value; + +const CARD_SCHEMA_VERSION: &str = "1.0.0"; +const SOURCE_CARDS: [&str; 6] = [ + "certificate-enrollment-pki.json", + "client-notification-bgb.json", + "cloud-service-connection.json", + "osd-pxe.json", + "reporting.json", + "sql-database-export.json", +]; +const CATALOG_FIXTURES: [&str; 4] = [ + "missing-required-field", + "redaction-required", + "unvalidated-source", + "valid", +]; +const REQUIRED_FIELDS: [&str; 20] = [ + "cardSchemaVersion", + "cardId", + "cardVersion", + "family", + "roleScope", + "candidateBasenames", + "pathClasses", + "rawParserFamily", + "sourceVersionScope", + "capture", + "privacy", + "expectedHealthyEvidence", + "terminalFailureEvidence", + "correlationPolicy", + "fixtureIds", + "ownerIssue", + "promotion", + "semanticPolicy", + "nextEvidence", + "supersession", +]; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SourceCard { + card_schema_version: String, + card_id: String, + card_version: String, + family: String, + role_scope: Vec, + candidate_basenames: Vec, + path_classes: Vec, + raw_parser_family: RawParserFamily, + source_version_scope: SourceVersionScope, + capture: CapturePolicy, + privacy: PrivacyPolicy, + expected_healthy_evidence: String, + terminal_failure_evidence: String, + correlation_policy: CorrelationPolicy, + fixture_ids: Vec, + owner_issue: String, + promotion: Promotion, + semantic_policy: SemanticPolicy, + next_evidence: Vec, + supersession: Supersession, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum RawParserFamily { + Ccm, + Unsupported, + Unknown(String), +} + +impl<'de> Deserialize<'de> for RawParserFamily { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(match value.as_str() { + "ccm" => Self::Ccm, + "unsupported" => Self::Unsupported, + _ => Self::Unknown(value), + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SourceVersionScope { + state: SourceVersionState, + allowed_prefixes: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum SourceVersionState { + Unknown, + Scoped, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CapturePolicy { + classification: CaptureClassification, + max_bytes: u64, + access_policy: AccessPolicy, + rotation_policy: RotationPolicy, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum CaptureClassification { + Mandatory, + Optional, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum AccessPolicy { + LeastPrivilegeNoEscalation, + ExplicitOperatorExport, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RotationPolicy { + kinds: Vec, + max_files: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PrivacyPolicy { + sensitivity: PrivacySensitivity, + classes: Vec, + redaction_required: bool, + public_projection: Vec, + raw_sensitive_field_projection: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +#[serde(rename_all = "camelCase")] +enum PrivacySensitivity { + Low, + Medium, + High, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CorrelationPolicy { + key_state: KeyState, + allowed_key_kinds: Vec, + time_only_eligible: bool, + topology_required: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum KeyState { + Unvalidated, + Validated, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum PromotionState { + Candidate, + Observed, + FixtureValidated, + RuleValidated, + Deferred, + Unknown(String), +} + +impl<'de> Deserialize<'de> for PromotionState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(match value.as_str() { + "candidate" => Self::Candidate, + "observed" => Self::Observed, + "fixtureValidated" => Self::FixtureValidated, + "ruleValidated" => Self::RuleValidated, + "deferred" => Self::Deferred, + _ => Self::Unknown(value), + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Promotion { + state: PromotionState, + observed_evidence_ids: Vec, + implementation_issue: Option, + production_reducer: Option, + deferred_reason: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SemanticPolicy { + capture_guidance_only: bool, + can_create_transactions: bool, + can_create_failure_findings: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Supersession { + state: SupersessionState, + supersedes: Vec, + superseded_by: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum SupersessionState { + Active, + Deprecated, +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedValidation { + schema_version: String, + valid: bool, + admitted_to_semantic_catalog: bool, + issues: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +struct Validation { + valid: bool, + admitted_to_semantic_catalog: bool, + issues: Vec, +} + +fn corpus_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/server/advanced_roles") +} + +fn read_json(path: &Path) -> Value { + let contents = 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 load_card(path: &Path) -> Result { + let value = read_json(path); + let object = value + .as_object() + .ok_or_else(|| "schemaDeserializeFailed:rootMustBeObject".to_owned())?; + let missing = REQUIRED_FIELDS + .iter() + .find(|field| !object.contains_key(**field)); + if let Some(field) = missing { + return Err(format!("missingField:{field}")); + } + + serde_json::from_value(value).map_err(|error| { + let message = error.to_string(); + if let Some(field) = message + .strip_prefix("unknown field `") + .and_then(|value| value.split('`').next()) + { + format!("unknownField:{field}") + } else { + format!("schemaDeserializeFailed:{message}") + } + }) +} + +fn is_sorted_unique(values: &[String]) -> bool { + values.windows(2).all(|pair| pair[0] < pair[1]) +} + +fn is_card_id(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !value.starts_with('-') + && !value.ends_with('-') + && !value.contains("--") +} + +fn is_version(value: &str) -> bool { + let parts = value.split('.').collect::>(); + parts.len() == 3 + && parts + .iter() + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) +} + +fn is_issue(value: &str) -> bool { + value.strip_prefix('#').is_some_and(|digits| { + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn nonempty_sorted(values: &[String]) -> bool { + !values.is_empty() + && is_sorted_unique(values) + && values.iter().all(|value| !value.trim().is_empty()) +} + +fn is_admitted(card: &SourceCard, issues: &[String]) -> bool { + issues.is_empty() + && matches!(card.promotion.state, PromotionState::RuleValidated) + && card.supersession.state == SupersessionState::Active +} + +fn validate_card(card: &SourceCard) -> Validation { + let mut issues = Vec::new(); + + if card.card_schema_version != CARD_SCHEMA_VERSION { + issues.push("schemaVersionUnsupported".to_owned()); + } + if !is_card_id(&card.card_id) { + issues.push("invalidCardId".to_owned()); + } + if !is_version(&card.card_version) { + issues.push("invalidCardVersion".to_owned()); + } + if card.family.trim().len() < 12 { + issues.push("familyDescriptionTooShort".to_owned()); + } + if !nonempty_sorted(&card.role_scope) { + issues.push("roleScopeMustBeSortedUnique".to_owned()); + } + if !nonempty_sorted(&card.candidate_basenames) + || card.candidate_basenames.iter().any(|basename| { + basename.contains(['/', '\\', '*']) + || basename == "." + || basename == ".." + || basename.trim() != basename + }) + { + issues.push("candidateBasenamesInvalid".to_owned()); + } + if !nonempty_sorted(&card.path_classes) { + issues.push("pathClassesMustBeSortedUnique".to_owned()); + } + if let RawParserFamily::Unknown(_) = &card.raw_parser_family { + issues.push("unknownRawParserFamily".to_owned()); + } + match card.source_version_scope.state { + SourceVersionState::Unknown if !card.source_version_scope.allowed_prefixes.is_empty() => { + issues.push("unknownVersionCannotDeclarePrefixes".to_owned()); + } + SourceVersionState::Scoped + if !nonempty_sorted(&card.source_version_scope.allowed_prefixes) => + { + issues.push("scopedVersionRequiresSortedPrefixes".to_owned()); + } + _ => {} + } + if card.capture.max_bytes == 0 || card.capture.max_bytes > 16 * 1024 * 1024 { + issues.push("captureMaxBytesOutOfRange".to_owned()); + } + let allowed_rotation_kinds = ["current", "lo_", "snapshot", "timestamped"]; + if !nonempty_sorted(&card.capture.rotation_policy.kinds) + || card + .capture + .rotation_policy + .kinds + .iter() + .any(|kind| !allowed_rotation_kinds.contains(&kind.as_str())) + || card.capture.rotation_policy.max_files == 0 + || card.capture.rotation_policy.max_files > 4 + { + issues.push("rotationPolicyInvalid".to_owned()); + } + if !is_sorted_unique(&card.privacy.classes) || !nonempty_sorted(&card.privacy.public_projection) + { + issues.push("privacyClassesOrProjectionUnsorted".to_owned()); + } + let allowed_public_projection = ["captureState", "cardId", "coverageState", "roleScope"]; + if card + .privacy + .public_projection + .iter() + .any(|field| !allowed_public_projection.contains(&field.as_str())) + { + issues.push("unsafePublicProjection".to_owned()); + } + if (!card.privacy.classes.is_empty() || card.privacy.sensitivity >= PrivacySensitivity::Medium) + && !card.privacy.redaction_required + { + issues.push("redactionRequired".to_owned()); + } + if !card.privacy.raw_sensitive_field_projection.is_empty() { + issues.push("rawSensitiveProjectionForbidden".to_owned()); + } + let descriptions = [ + &card.expected_healthy_evidence, + &card.terminal_failure_evidence, + ]; + if descriptions.iter().any(|description| { + description.trim().len() < 40 + || description + .to_ascii_lowercase() + .contains("parse log and identify errors") + }) { + issues.push("evidenceDescriptionNotSpecific".to_owned()); + } + if card.correlation_policy.time_only_eligible { + issues.push("timeOnlyCorrelationForbidden".to_owned()); + } + if !card.correlation_policy.topology_required { + issues.push("topologyRequirementMissing".to_owned()); + } + if card.correlation_policy.key_state == KeyState::Unvalidated + && !card.correlation_policy.allowed_key_kinds.is_empty() + { + issues.push("unvalidatedKeysCannotBeDeclared".to_owned()); + } + if card.correlation_policy.key_state == KeyState::Validated + && !nonempty_sorted(&card.correlation_policy.allowed_key_kinds) + { + issues.push("validatedKeysMustBeSorted".to_owned()); + } + if !is_issue(&card.owner_issue) { + issues.push("ownerIssueInvalid".to_owned()); + } + if !is_sorted_unique(&card.fixture_ids) { + issues.push("fixtureIdsMustBeSortedUnique".to_owned()); + } + if !is_sorted_unique(&card.promotion.observed_evidence_ids) { + issues.push("observedEvidenceIdsMustBeSortedUnique".to_owned()); + } + if card.next_evidence.is_empty() || card.next_evidence.iter().any(|item| item.trim().len() < 30) + { + issues.push("nextEvidenceNotSpecific".to_owned()); + } + if !is_sorted_unique(&card.supersession.supersedes) { + issues.push("supersedesMustBeSortedUnique".to_owned()); + } + match card.supersession.state { + SupersessionState::Active if card.supersession.superseded_by.is_some() => { + issues.push("activeCardCannotBeSuperseded".to_owned()); + } + SupersessionState::Deprecated + if card + .supersession + .superseded_by + .as_deref() + .is_none_or(str::is_empty) => + { + issues.push("deprecatedCardRequiresSuccessor".to_owned()); + } + SupersessionState::Deprecated + if card + .supersession + .superseded_by + .as_deref() + .is_some_and(|successor| !is_card_id(successor) || successor == card.card_id) => + { + issues.push("supersessionSuccessorInvalid".to_owned()); + } + _ => {} + } + + match &card.promotion.state { + PromotionState::Candidate => { + if !card.promotion.observed_evidence_ids.is_empty() + || card.promotion.implementation_issue.is_some() + || card.promotion.production_reducer.is_some() + || card.promotion.deferred_reason.is_some() + { + issues.push("candidateMetadataInvalid".to_owned()); + } + } + PromotionState::Observed => { + if card.promotion.observed_evidence_ids.is_empty() + || card.promotion.implementation_issue.is_some() + || card.promotion.production_reducer.is_some() + || card.promotion.deferred_reason.is_some() + { + issues.push("observedMetadataInvalid".to_owned()); + } + } + PromotionState::FixtureValidated => { + if card.promotion.observed_evidence_ids.is_empty() + || card.fixture_ids.is_empty() + || card.promotion.implementation_issue.is_some() + || card.promotion.production_reducer.is_some() + || card.promotion.deferred_reason.is_some() + { + issues.push("fixtureValidatedMetadataInvalid".to_owned()); + } + } + PromotionState::RuleValidated => { + if card.promotion.observed_evidence_ids.is_empty() + || card.fixture_ids.is_empty() + || card + .promotion + .implementation_issue + .as_deref() + .is_none_or(|issue| !is_issue(issue)) + || card + .promotion + .production_reducer + .as_deref() + .is_none_or(str::is_empty) + || card.promotion.deferred_reason.is_some() + || card.raw_parser_family != RawParserFamily::Ccm + || card.source_version_scope.state != SourceVersionState::Scoped + { + issues.push("ruleValidatedMetadataInvalid".to_owned()); + } + } + PromotionState::Deferred => { + if card + .promotion + .deferred_reason + .as_deref() + .is_none_or(|reason| reason.trim().len() < 30) + || card.promotion.production_reducer.is_some() + || card.promotion.implementation_issue.is_some() + { + issues.push("deferredMetadataInvalid".to_owned()); + } + } + PromotionState::Unknown(_) => issues.push("unknownPromotionState".to_owned()), + } + if matches!(card.promotion.state, PromotionState::RuleValidated) + && (card.correlation_policy.key_state != KeyState::Validated + || !nonempty_sorted(&card.correlation_policy.allowed_key_kinds)) + { + issues.push("ruleValidatedKeyPolicyInvalid".to_owned()); + } + + let guidance_only = matches!( + card.promotion.state, + PromotionState::Candidate + | PromotionState::Observed + | PromotionState::FixtureValidated + | PromotionState::Deferred + ); + if guidance_only + && (!card.semantic_policy.capture_guidance_only + || card.semantic_policy.can_create_transactions + || card.semantic_policy.can_create_failure_findings) + { + issues.push("unvalidatedSourceCannotDiagnose".to_owned()); + } + if matches!(card.promotion.state, PromotionState::RuleValidated) + && (card.semantic_policy.capture_guidance_only + || !card.semantic_policy.can_create_transactions) + { + issues.push("ruleValidatedSemanticPolicyInvalid".to_owned()); + } + + issues.sort(); + issues.dedup(); + let admitted_to_semantic_catalog = is_admitted(card, &issues); + Validation { + valid: issues.is_empty(), + admitted_to_semantic_catalog, + issues, + } +} + +fn validate_card_with_inventory(card: &SourceCard, inventory: &BTreeSet) -> Validation { + let mut validation = validate_card(card); + if card.supersession.state == SupersessionState::Deprecated + && card + .supersession + .superseded_by + .as_ref() + .is_some_and(|successor| !inventory.contains(successor)) + { + validation + .issues + .push("supersessionSuccessorMissing".to_owned()); + } + if card + .supersession + .supersedes + .iter() + .any(|predecessor| !is_card_id(predecessor) || !inventory.contains(predecessor)) + { + validation + .issues + .push("supersededPredecessorMissing".to_owned()); + } + validation.issues.sort(); + validation.issues.dedup(); + validation.valid = validation.issues.is_empty(); + validation.admitted_to_semantic_catalog = is_admitted(card, &validation.issues); + validation +} + +fn validate_catalog(cards: &[SourceCard]) -> Vec { + let inventory = cards + .iter() + .map(|card| card.card_id.clone()) + .collect::>(); + let cycle_members = supersession_cycle_members(cards); + cards + .iter() + .map(|card| { + let mut validation = validate_card_with_inventory(card, &inventory); + if cycle_members.contains(&card.card_id) { + validation + .issues + .push("supersessionCycleDetected".to_owned()); + validation.issues.sort(); + validation.issues.dedup(); + validation.valid = false; + validation.admitted_to_semantic_catalog = false; + } + validation + }) + .collect() +} + +fn supersession_cycle_members(cards: &[SourceCard]) -> BTreeSet { + let mut graph = cards + .iter() + .map(|card| (card.card_id.clone(), Vec::new())) + .collect::>(); + + for card in cards { + if let Some(successor) = &card.supersession.superseded_by { + graph + .entry(card.card_id.clone()) + .or_default() + .push(successor.clone()); + } + for predecessor in &card.supersession.supersedes { + graph + .entry(predecessor.clone()) + .or_default() + .push(card.card_id.clone()); + } + } + + cards + .iter() + .filter(|card| { + supersession_cycle_reaches(&card.card_id, &card.card_id, &graph, &mut BTreeSet::new()) + }) + .map(|card| card.card_id.clone()) + .collect() +} + +fn supersession_cycle_reaches( + origin: &str, + current: &str, + graph: &BTreeMap>, + visited: &mut BTreeSet, +) -> bool { + graph.get(current).is_some_and(|targets| { + targets.iter().any(|target| { + if target == origin { + return true; + } + if !visited.insert(target.clone()) { + return false; + } + let reaches_origin = supersession_cycle_reaches(origin, target, graph, visited); + visited.remove(target); + reaches_origin + }) + }) +} + +fn validate_path(path: &Path) -> Validation { + match load_card(path) { + Ok(card) => validate_card(&card), + Err(issue) => Validation { + valid: false, + admitted_to_semantic_catalog: false, + issues: vec![issue], + }, + } +} + +#[test] +fn advanced_role_source_card_inventory_is_exact_and_sorted() { + let root = corpus_root().join("source-cards"); + let actual = fs::read_dir(&root) + .expect("advanced-role source-card root exists") + .map(|entry| { + entry + .expect("source-card entry is readable") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + let expected = SOURCE_CARDS + .into_iter() + .map(str::to_owned) + .collect::>(); + assert_eq!(actual, expected); +} + +#[test] +fn candidate_catalog_is_typed_private_and_not_semantically_admitted() { + let root = corpus_root().join("source-cards"); + let cards = SOURCE_CARDS + .iter() + .map(|filename| { + load_card(&root.join(filename)).unwrap_or_else(|error| panic!("{filename}: {error}")) + }) + .collect::>(); + let inventory = cards + .iter() + .map(|card| card.card_id.clone()) + .collect::>(); + assert_eq!( + inventory.len(), + cards.len(), + "source-card IDs must be unique" + ); + let validations = validate_catalog(&cards); + let mut card_ids = Vec::new(); + for ((filename, card), validation) in SOURCE_CARDS.into_iter().zip(cards).zip(validations) { + assert!( + validation.valid, + "{filename}: {}", + validation.issues.join(", ") + ); + assert!( + !validation.admitted_to_semantic_catalog, + "{filename}: prep-only source cards cannot enter semantic analyzers" + ); + assert!( + matches!( + card.promotion.state, + PromotionState::Candidate | PromotionState::Deferred + ), + "{filename}: no lab-observed or rule-validated evidence exists" + ); + assert!(card.semantic_policy.capture_guidance_only); + assert!(!card.semantic_policy.can_create_transactions); + assert!(!card.semantic_policy.can_create_failure_findings); + card_ids.push(card.card_id); + } + assert!( + is_sorted_unique(&card_ids), + "source-card filenames must yield a deterministic card-id order" + ); +} + +#[test] +fn catalog_fixture_matrix_has_exact_deterministic_admission_results() { + let root = corpus_root().join("catalog-fixtures"); + let actual = fs::read_dir(&root) + .expect("advanced-role catalog-fixture root exists") + .map(|entry| { + entry + .expect("catalog-fixture entry is readable") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + let expected_names = CATALOG_FIXTURES + .into_iter() + .map(str::to_owned) + .collect::>(); + assert_eq!(actual, expected_names); + + for fixture in CATALOG_FIXTURES { + let fixture_root = root.join(fixture); + let actual = validate_path(&fixture_root.join("source-card.json")); + let expected: ExpectedValidation = + serde_json::from_value(read_json(&fixture_root.join("expected.json"))) + .unwrap_or_else(|error| panic!("{fixture}: expected result is typed: {error}")); + assert_eq!(expected.schema_version, CARD_SCHEMA_VERSION); + assert_eq!( + actual, + Validation { + valid: expected.valid, + admitted_to_semantic_catalog: expected.admitted_to_semantic_catalog, + issues: expected.issues, + }, + "{fixture}" + ); + } +} + +#[test] +fn unknown_parser_and_promotion_values_are_preserved_then_rejected() { + let path = corpus_root() + .join("catalog-fixtures/valid") + .join("source-card.json"); + let mut value = read_json(&path); + value["rawParserFamily"] = Value::String("future-binary-parser".to_owned()); + value["promotion"]["state"] = Value::String("futurePromotion".to_owned()); + let card: SourceCard = + serde_json::from_value(value).expect("unknown values remain inspectable card data"); + assert_eq!( + card.raw_parser_family, + RawParserFamily::Unknown("future-binary-parser".to_owned()) + ); + assert_eq!( + card.promotion.state, + PromotionState::Unknown("futurePromotion".to_owned()) + ); + let validation = validate_card(&card); + assert_eq!( + validation.issues, + ["unknownPromotionState", "unknownRawParserFamily"] + ); + assert!(!validation.admitted_to_semantic_catalog); +} + +#[test] +fn deprecation_requires_an_explicit_successor_and_never_panics() { + let path = corpus_root() + .join("catalog-fixtures/valid") + .join("source-card.json"); + let mut card = load_card(&path).expect("valid fixture loads"); + card.supersession.state = SupersessionState::Deprecated; + let invalid = validate_card(&card); + assert!(invalid + .issues + .contains(&"deprecatedCardRequiresSuccessor".to_owned())); + + card.source_version_scope.state = SourceVersionState::Scoped; + card.source_version_scope.allowed_prefixes = vec!["5.00.".to_owned()]; + card.correlation_policy.key_state = KeyState::Validated; + card.correlation_policy.allowed_key_kinds = vec!["requestId".to_owned()]; + card.fixture_ids = vec!["advanced-role-rule-success".to_owned()]; + card.promotion.state = PromotionState::RuleValidated; + card.promotion.observed_evidence_ids = vec!["sanitized-lab-role-path-version-001".to_owned()]; + card.promotion.implementation_issue = Some("#400".to_owned()); + card.promotion.production_reducer = Some("sccm.server.synthetic.reduce".to_owned()); + card.semantic_policy.capture_guidance_only = false; + card.semantic_policy.can_create_transactions = true; + card.semantic_policy.can_create_failure_findings = true; + card.supersession.superseded_by = Some("missing-successor".to_owned()); + let inventory = [card.card_id.clone()].into_iter().collect(); + let dangling = validate_card_with_inventory(&card, &inventory); + assert!(dangling + .issues + .contains(&"supersessionSuccessorMissing".to_owned())); + assert!(!dangling.admitted_to_semantic_catalog); + + card.supersession.superseded_by = Some("advanced-role-successor".to_owned()); + let inventory = [card.card_id.clone(), "advanced-role-successor".to_owned()] + .into_iter() + .collect(); + let valid = validate_card_with_inventory(&card, &inventory); + assert!(!valid + .issues + .contains(&"supersessionSuccessorMissing".to_owned())); + assert!(valid.valid, "an existing successor keeps the card valid"); + assert!( + !valid.admitted_to_semantic_catalog, + "deprecation metadata cannot admit even a RuleValidated source" + ); + + card.supersession.supersedes = vec!["advanced-role-predecessor".to_owned()]; + let dangling_predecessor = validate_card_with_inventory(&card, &inventory); + assert_eq!( + dangling_predecessor.issues, + ["supersededPredecessorMissing"], + "a supersedes entry must resolve against the catalog inventory" + ); + assert!(!dangling_predecessor.admitted_to_semantic_catalog); + + let inventory = [ + card.card_id.clone(), + "advanced-role-predecessor".to_owned(), + "advanced-role-successor".to_owned(), + ] + .into_iter() + .collect(); + let resolved = validate_card_with_inventory(&card, &inventory); + assert!( + resolved.valid, + "a supersedes entry present in the catalog keeps the card valid" + ); +} + +#[test] +fn supersession_self_references_and_cycles_fail_closed() { + let path = corpus_root() + .join("catalog-fixtures/valid") + .join("source-card.json"); + let mut self_referential = load_card(&path).expect("valid fixture loads"); + self_referential.supersession.supersedes = vec![self_referential.card_id.clone()]; + let self_validation = validate_catalog(&[self_referential]); + assert_eq!(self_validation[0].issues, ["supersessionCycleDetected"]); + assert!(!self_validation[0].admitted_to_semantic_catalog); + + let mut alpha = load_card(&path).expect("valid fixture loads"); + alpha.card_id = "alpha-card".to_owned(); + alpha.supersession.supersedes = vec!["bravo-card".to_owned()]; + let mut bravo = load_card(&path).expect("valid fixture loads"); + bravo.card_id = "bravo-card".to_owned(); + bravo.supersession.supersedes = vec!["charlie-card".to_owned()]; + let mut charlie = load_card(&path).expect("valid fixture loads"); + charlie.card_id = "charlie-card".to_owned(); + charlie.supersession.supersedes = vec!["alpha-card".to_owned()]; + + let cycle_validations = validate_catalog(&[alpha, bravo, charlie]); + assert!(cycle_validations.iter().all(|validation| { + validation.issues == ["supersessionCycleDetected"] + && !validation.admitted_to_semantic_catalog + })); + + let mut predecessor = load_card(&path).expect("valid fixture loads"); + predecessor.card_id = "predecessor-card".to_owned(); + predecessor.supersession.state = SupersessionState::Deprecated; + predecessor.supersession.superseded_by = Some("successor-card".to_owned()); + let mut successor = load_card(&path).expect("valid fixture loads"); + successor.card_id = "successor-card".to_owned(); + successor.supersession.supersedes = vec!["predecessor-card".to_owned()]; + + let reciprocal_validations = validate_catalog(&[predecessor, successor]); + assert!(reciprocal_validations + .iter() + .all(|validation| validation.valid)); +} + +#[test] +fn only_a_fully_linked_rule_validated_card_is_semantically_admitted() { + let path = corpus_root() + .join("catalog-fixtures/valid") + .join("source-card.json"); + let mut card = load_card(&path).expect("valid fixture loads"); + card.source_version_scope.state = SourceVersionState::Scoped; + card.source_version_scope.allowed_prefixes = vec!["5.00.".to_owned()]; + card.correlation_policy.key_state = KeyState::Validated; + card.correlation_policy.allowed_key_kinds = vec!["requestId".to_owned()]; + card.fixture_ids = vec!["advanced-role-rule-success".to_owned()]; + card.promotion.state = PromotionState::RuleValidated; + card.promotion.observed_evidence_ids = vec!["sanitized-lab-role-path-version-001".to_owned()]; + card.promotion.implementation_issue = Some("#400".to_owned()); + card.promotion.production_reducer = Some("sccm.server.synthetic.reduce".to_owned()); + card.semantic_policy.capture_guidance_only = false; + card.semantic_policy.can_create_transactions = true; + card.semantic_policy.can_create_failure_findings = true; + + let admitted = validate_card(&card); + assert_eq!( + admitted, + Validation { + valid: true, + admitted_to_semantic_catalog: true, + issues: Vec::new(), + } + ); + + card.promotion.implementation_issue = None; + let blocked = validate_card(&card); + assert_eq!(blocked.issues, ["ruleValidatedMetadataInvalid"]); + assert!(!blocked.admitted_to_semantic_catalog); + + card.promotion.implementation_issue = Some("#400".to_owned()); + card.correlation_policy.key_state = KeyState::Unvalidated; + card.correlation_policy.allowed_key_kinds.clear(); + let unvalidated_key = validate_card(&card); + assert_eq!(unvalidated_key.issues, ["ruleValidatedKeyPolicyInvalid"]); + assert!(!unvalidated_key.admitted_to_semantic_catalog); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs new file mode 100644 index 000000000..26074d893 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs @@ -0,0 +1,1693 @@ +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::server::windows::{ + analyze_distribution_point, analyze_distribution_point_content_from_server_intake, + assess_server_intake, SccmDistributionPointContentConfidence, + SccmDistributionPointContentPhase, SccmDistributionPointContentState, + SccmDistributionPointContentTransaction, SccmServerArtifactPayload, SccmServerIntakeAssessment, + SccmServerIntakeError, SCCM_DISTRIBUTION_POINT_ANALYSIS_SCHEMA_VERSION, + SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_ID, SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_VERSION, +}; +use cmtraceopen_parser::sccm::{ + SccmArtifactRequest, SccmCoverageState, SccmRole, SccmRotation, SccmTimeOrderingState, +}; +use serde_json::{json, Value}; + +fn artifact_request_contracts(requests: &[SccmArtifactRequest]) -> Vec<(&str, SccmRole, &str)> { + requests + .iter() + .map(|request| { + ( + request.logical_id.as_str(), + request.role.clone(), + request.reason.as_str(), + ) + }) + .collect() +} + +fn observation_citations( + transaction: &SccmDistributionPointContentTransaction, +) -> Vec<(SccmDistributionPointContentPhase, &str, Option)> { + transaction + .observations + .iter() + .map(|observation| { + ( + observation.phase, + observation.evidence.artifact_id.as_str(), + observation.evidence.line_start, + ) + }) + .collect() +} + +#[test] +fn production_content_lifecycle_covers_success_failure_and_bounded_progress_states() { + let cases = [ + ( + "healthy-package", + SccmDistributionPointContentState::Succeeded, + Some(SccmDistributionPointContentPhase::MakeAvailable), + None, + 0, + ), + ( + "distribution-failure", + SccmDistributionPointContentState::Failed, + Some(SccmDistributionPointContentPhase::ReceiveContent), + Some(SccmDistributionPointContentPhase::Distribute), + 1, + ), + ( + "transfer-failure", + SccmDistributionPointContentState::Failed, + Some(SccmDistributionPointContentPhase::Distribute), + Some(SccmDistributionPointContentPhase::Transfer), + 1, + ), + ( + "validation-failure", + SccmDistributionPointContentState::Failed, + Some(SccmDistributionPointContentPhase::Transfer), + Some(SccmDistributionPointContentPhase::Validate), + 1, + ), + ( + "transfer-retry", + SccmDistributionPointContentState::Retrying, + Some(SccmDistributionPointContentPhase::Distribute), + Some(SccmDistributionPointContentPhase::Transfer), + 0, + ), + ( + "backlog-blocked", + SccmDistributionPointContentState::Blocked, + Some(SccmDistributionPointContentPhase::Distribute), + Some(SccmDistributionPointContentPhase::Transfer), + 0, + ), + ( + "transfer-deferred", + SccmDistributionPointContentState::Deferred, + Some(SccmDistributionPointContentPhase::Distribute), + Some(SccmDistributionPointContentPhase::Transfer), + 0, + ), + ]; + + for (scenario, state, last_phase, stop_phase, terminal_evidence_count) in cases { + let assessment = load_distribution_point_assessment(scenario); + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .unwrap_or_else(|error| panic!("{scenario} remains analyzable: {error}")); + assert_eq!(analysis.transactions.len(), 1, "{scenario}"); + let transaction = &analysis.transactions[0]; + assert_eq!(transaction.state, state, "{scenario}"); + assert_eq!(transaction.last_proven_phase, last_phase, "{scenario}"); + assert_eq!(transaction.stop_phase, stop_phase, "{scenario}"); + assert_eq!( + transaction.terminal_evidence.len(), + terminal_evidence_count, + "{scenario}" + ); + if state == SccmDistributionPointContentState::Failed + || state == SccmDistributionPointContentState::Succeeded + { + assert!(transaction.next_artifact.is_none(), "{scenario}"); + } else { + assert_eq!( + transaction + .next_artifact + .as_ref() + .map(|request| request.logical_id.as_str()), + Some("pkgXferMgr"), + "{scenario}" + ); + } + } +} + +#[test] +fn optional_serve_recovery_and_exact_source_evidence_are_preserved() { + let served = analyze_distribution_point_content_from_server_intake( + &load_distribution_point_assessment("serve-observed"), + ) + .expect("optional serving evidence is analyzable"); + assert_eq!(served.transactions.len(), 1); + let served = &served.transactions[0]; + assert_eq!( + served.last_proven_phase, + Some(SccmDistributionPointContentPhase::ServeOrReport) + ); + assert_eq!( + served.confidence, + SccmDistributionPointContentConfidence::High + ); + assert_eq!( + served + .observations + .last() + .map(|item| item.source_id.as_str()), + Some("server-dp-serve") + ); + + let recovered = analyze_distribution_point_content_from_server_intake( + &load_distribution_point_assessment("contradiction-recovery"), + ) + .expect("later exact success can recover an earlier terminal transfer failure"); + assert_eq!(recovered.transactions.len(), 1); + let recovered = &recovered.transactions[0]; + assert_eq!( + recovered.state, + SccmDistributionPointContentState::Succeeded + ); + assert!(recovered.recovered); + assert_eq!(recovered.terminal_evidence.len(), 1); + assert_eq!( + recovered.terminal_evidence[0].artifact_id, + "dp-recovery-02-pkgxfer" + ); +} + +#[test] +fn unresolved_same_timestamp_outcomes_are_contradictory_and_request_one_source() { + let assessment = load_distribution_point_assessment_after("transfer-retry", |_, payloads| { + let payload = payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == "dp-transfer-retry-02-pkgxfer") + .expect("retry fixture has transfer evidence"); + let content = std::str::from_utf8(&payload.bytes).expect("fixture is UTF-8"); + let conflict = content.replace("Disposition=retrying", "Disposition=succeeded"); + payload.bytes.extend_from_slice(conflict.as_bytes()); + }); + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("contradictory sealed evidence remains analyzable"); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!( + analysis.transactions[0].state, + SccmDistributionPointContentState::Contradictory + ); + assert_eq!( + analysis.transactions[0] + .next_artifact + .as_ref() + .map(|request| request.logical_id.as_str()), + Some("pkgXferMgr") + ); +} + +#[test] +fn downstream_after_terminal_phase_is_cited_as_decisive_contradiction_evidence() { + let assessment = load_distribution_point_assessment_after("healthy-package", |_, payloads| { + let transfer = payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == "dp-healthy-02-pkgxfer") + .expect("healthy fixture has transfer evidence"); + let content = std::str::from_utf8(&transfer.bytes).expect("fixture is UTF-8"); + transfer.bytes = content + .replace( + "Disposition=succeeded; Terminal=false", + "Disposition=failed; Terminal=true", + ) + .into_bytes(); + }); + + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("downstream evidence remains analyzable"); + let transaction = &analysis.transactions[0]; + assert_eq!( + transaction.state, + SccmDistributionPointContentState::Contradictory + ); + assert_eq!(transaction.evidence.len(), 4); + assert_eq!( + observation_citations(transaction), + vec![ + ( + SccmDistributionPointContentPhase::ReceiveContent, + "dp-healthy-01-distmgr", + Some(1), + ), + ( + SccmDistributionPointContentPhase::Distribute, + "dp-healthy-01-distmgr", + Some(2), + ), + ( + SccmDistributionPointContentPhase::Transfer, + "dp-healthy-02-pkgxfer", + Some(1), + ), + ( + SccmDistributionPointContentPhase::Validate, + "dp-healthy-03-provider", + Some(1), + ), + ] + ); + let serialized = serde_json::to_string(&analysis).expect("analysis serializes"); + for private in ["SYNTHETIC FIXTURE", ".cpp:", "LAB-CM01", "safe:server:"] { + assert!(!serialized.contains(private), "output leaks {private}"); + } +} + +#[test] +fn missing_phase_cites_only_the_first_decisive_downstream_fact() { + let assessment = load_distribution_point_assessment_after("healthy-package", |_, payloads| { + let provider = payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == "dp-healthy-03-provider") + .expect("healthy fixture has provider evidence"); + let content = std::str::from_utf8(&provider.bytes).expect("fixture is UTF-8"); + provider.bytes = content + .lines() + .filter(|line| !line.contains("Phase=validate")) + .collect::>() + .join("\n") + .into_bytes(); + }); + + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("missing phase remains analyzable"); + let transaction = &analysis.transactions[0]; + assert_eq!( + transaction.state, + SccmDistributionPointContentState::Contradictory + ); + assert_eq!(transaction.evidence.len(), 4); + assert_eq!( + observation_citations(transaction), + vec![ + ( + SccmDistributionPointContentPhase::ReceiveContent, + "dp-healthy-01-distmgr", + Some(1), + ), + ( + SccmDistributionPointContentPhase::Distribute, + "dp-healthy-01-distmgr", + Some(2), + ), + ( + SccmDistributionPointContentPhase::Transfer, + "dp-healthy-02-pkgxfer", + Some(1), + ), + ( + SccmDistributionPointContentPhase::MakeAvailable, + "dp-healthy-03-provider", + Some(1), + ), + ] + ); +} + +#[test] +fn non_monotonic_phase_cites_the_exact_out_of_order_fact() { + let assessment = load_distribution_point_assessment_after("healthy-package", |_, payloads| { + let provider = payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == "dp-healthy-03-provider") + .expect("healthy fixture has provider evidence"); + let content = std::str::from_utf8(&provider.bytes).expect("fixture is UTF-8"); + provider.bytes = content + .replace("12:00:03.000+000", "12:00:01.500+000") + .into_bytes(); + }); + + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("non-monotonic phase remains analyzable"); + let transaction = &analysis.transactions[0]; + assert_eq!( + transaction.state, + SccmDistributionPointContentState::Contradictory + ); + assert_eq!(transaction.evidence.len(), 4); + assert_eq!( + observation_citations(transaction), + vec![ + ( + SccmDistributionPointContentPhase::ReceiveContent, + "dp-healthy-01-distmgr", + Some(1), + ), + ( + SccmDistributionPointContentPhase::Distribute, + "dp-healthy-01-distmgr", + Some(2), + ), + ( + SccmDistributionPointContentPhase::Transfer, + "dp-healthy-02-pkgxfer", + Some(1), + ), + ( + SccmDistributionPointContentPhase::Validate, + "dp-healthy-03-provider", + Some(1), + ), + ] + ); +} + +#[test] +fn non_monotonic_optional_report_is_cited_without_unrelated_evidence() { + let assessment = load_distribution_point_assessment_after("serve-observed", |_, payloads| { + let status = payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == "dp-serve-04-status") + .expect("serve fixture has status evidence"); + let content = std::str::from_utf8(&status.bytes).expect("fixture is UTF-8"); + status.bytes = content + .replace("12:06:05.000+000", "12:06:03.500+000") + .into_bytes(); + }); + + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("non-monotonic optional report remains analyzable"); + let transaction = &analysis.transactions[0]; + assert_eq!( + transaction.state, + SccmDistributionPointContentState::Contradictory + ); + assert_eq!(transaction.evidence.len(), 6); + assert_eq!( + observation_citations(transaction), + vec![ + ( + SccmDistributionPointContentPhase::ReceiveContent, + "dp-serve-01-distmgr", + Some(1), + ), + ( + SccmDistributionPointContentPhase::Distribute, + "dp-serve-01-distmgr", + Some(2), + ), + ( + SccmDistributionPointContentPhase::Transfer, + "dp-serve-02-pkgxfer", + Some(1), + ), + ( + SccmDistributionPointContentPhase::Validate, + "dp-serve-03-provider", + Some(1), + ), + ( + SccmDistributionPointContentPhase::MakeAvailable, + "dp-serve-03-provider", + Some(2), + ), + ( + SccmDistributionPointContentPhase::ServeOrReport, + "dp-serve-04-status", + Some(1), + ), + ] + ); +} + +#[test] +fn multiple_distribution_points_and_versions_sort_by_the_full_sealed_key() { + let assessment = load_distribution_point_assessment("content-version-mismatch"); + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("multi-DP version evidence is analyzable"); + assert_eq!(analysis.transactions.len(), 3); + assert!(analysis + .transactions + .windows(2) + .all(|pair| pair[0].key < pair[1].key)); + assert_eq!( + analysis + .transactions + .iter() + .map(|transaction| ( + transaction.key.distribution_point_handle.as_str(), + transaction.key.content_version, + transaction.content_version_mismatch, + transaction.state, + )) + .collect::>(), + vec![ + ( + "synthetic:subject:dp-01", + 1, + true, + SccmDistributionPointContentState::Succeeded, + ), + ( + "synthetic:subject:dp-02", + 1, + false, + SccmDistributionPointContentState::Succeeded, + ), + ( + "synthetic:subject:dp-01", + 2, + true, + SccmDistributionPointContentState::Retrying, + ), + ] + ); + + let mut reordered = assessment.clone(); + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + reordered.topology.roles_observed.reverse(); + assert_eq!( + serde_json::to_value(analysis).expect("analysis serializes"), + serde_json::to_value( + analyze_distribution_point_content_from_server_intake(&reordered) + .expect("reordered intake is analyzable"), + ) + .expect("reordered analysis serializes") + ); +} + +#[test] +fn production_matrix_matches_exact_full_output_oracles() { + for scenario in [ + "healthy-package", + "distribution-failure", + "transfer-failure", + "transfer-retry", + "backlog-blocked", + "transfer-deferred", + "validation-failure", + "serve-observed", + "contradiction-recovery", + "content-version-mismatch", + "incomplete", + "rotation-boundary", + "malformed-current", + "absent-dp", + ] { + let assessment = load_distribution_point_assessment(scenario); + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .unwrap_or_else(|error| panic!("{scenario} remains analyzable: {error}")); + let actual = serde_json::to_value(analysis).expect("analysis serializes"); + let expected_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/distribution_point") + .join(scenario) + .join("expected.json"); + let expected: Value = serde_json::from_str( + &std::fs::read_to_string(&expected_path).expect("oracle is readable"), + ) + .expect("oracle is valid JSON"); + assert_eq!(actual, expected, "{scenario}"); + } +} + +#[test] +fn full_output_oracles_detect_omitted_and_mutated_lifecycle_authority() { + let assessment = load_distribution_point_assessment("distribution-failure"); + let actual = serde_json::to_value( + analyze_distribution_point_content_from_server_intake(&assessment) + .expect("terminal failure remains analyzable"), + ) + .expect("analysis serializes"); + + let mut omitted = actual.clone(); + omitted["transactions"][0] + .as_object_mut() + .expect("transaction is an object") + .remove("terminalEvidence"); + assert_ne!(actual, omitted, "terminal authority is oracle-visible"); + + let mut mutated = actual.clone(); + mutated["transactions"][0]["terminalEvidence"][0]["lineStart"] = json!(1); + assert_ne!( + actual, mutated, + "exact evidence coordinates are oracle-visible" + ); + + let mut weakened = actual.clone(); + weakened["transactions"][0]["classification"] = json!("success"); + weakened["transactions"][0]["severity"] = json!("Success"); + assert_ne!( + actual, weakened, + "class and severity cannot be silently weakened" + ); +} + +#[test] +fn production_output_contains_no_self_attested_profile_or_raw_path_claims() { + for scenario in [ + "healthy-package", + "distribution-failure", + "serve-observed", + "content-version-mismatch", + "incomplete", + ] { + let output = serde_json::to_string( + &analyze_distribution_point_content_from_server_intake( + &load_distribution_point_assessment(scenario), + ) + .unwrap_or_else(|error| panic!("{scenario} remains analyzable: {error}")), + ) + .expect("analysis serializes"); + assert!(!output.contains("ProfileId="), "{scenario}"); + assert!(!output.contains("originalPath"), "{scenario}"); + assert!(!output.contains("sanitizedSourcePath"), "{scenario}"); + assert!(!output.contains("safe:server:"), "{scenario}"); + } +} + +fn expected_site_server_artifact_requests() -> Vec<(&'static str, SccmRole, &'static str)> { + vec![ + ( + "distmgr", + SccmRole::SiteServer, + "Collect the complete distmgr.log file.", + ), + ( + "pkgXferMgr", + SccmRole::SiteServer, + "Collect the complete PkgXferMgr.log file.", + ), + ] +} + +fn expected_all_dp_profile_artifact_requests() -> Vec<(&'static str, SccmRole, &'static str)> { + let mut requests = expected_site_server_artifact_requests(); + requests.push(( + "smsDpProv", + SccmRole::DistributionPoint, + "Collect the complete SMSDPProv.log file.", + )); + requests +} + +fn expected_dp_artifact_requests() -> Vec<(&'static str, SccmRole, &'static str)> { + vec![( + "smsDpProv", + SccmRole::DistributionPoint, + "Collect the complete SMSDPProv.log file.", + )] +} + +fn intake_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/server/intake") +} + +fn load_manifest_and_payloads(scenario: &str) -> (Value, Vec) { + let scenario_root = intake_root().join(scenario); + let manifest_json = + std::fs::read_to_string(scenario_root.join("manifest.json")).expect("manifest is readable"); + let manifest: Value = serde_json::from_str(&manifest_json).expect("manifest is valid JSON"); + let payloads = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"] + .as_str() + .expect("artifact id is a string") + .to_owned(), + bytes: std::fs::read(scenario_root.join(relative_path)) + .expect("captured evidence is readable"), + }) + }) + .collect::>(); + + (manifest, payloads) +} + +fn assess_manifest( + manifest: &Value, + payloads: &[SccmServerArtifactPayload], +) -> Result { + let manifest_json = serde_json::to_string(manifest).expect("manifest serializes"); + assess_server_intake(&manifest_json, payloads) +} + +fn assess_complete_manifest_after( + mutate: impl FnOnce(&mut Value), +) -> Result { + assess_complete_manifest_and_payloads_after(|manifest, _| mutate(manifest)) +} + +fn assess_complete_manifest_and_payloads_after( + mutate: impl FnOnce(&mut Value, &mut Vec), +) -> Result { + let (mut manifest, payloads) = load_manifest_and_payloads("complete-multi-role"); + let mut payloads = payloads; + mutate(&mut manifest, &mut payloads); + assess_manifest(&manifest, &payloads) +} + +fn load_assessment(scenario: &str) -> SccmServerIntakeAssessment { + let (manifest, payloads) = load_manifest_and_payloads(scenario); + assess_manifest(&manifest, &payloads).expect("fixture intake is accepted") +} + +fn load_distribution_point_assessment(scenario: &str) -> SccmServerIntakeAssessment { + load_distribution_point_assessment_after(scenario, |_, _| {}) +} + +fn load_distribution_point_assessment_after( + scenario: &str, + mutate: impl FnOnce(&mut Value, &mut Vec), +) -> SccmServerIntakeAssessment { + let scenario_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/distribution_point") + .join(scenario); + let fixture_manifest_json = + std::fs::read_to_string(scenario_root.join("manifest.json")).expect("manifest is readable"); + let mut manifest: Value = + serde_json::from_str(&fixture_manifest_json).expect("manifest is valid JSON"); + let mut payloads = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"] + .as_str() + .expect("artifact id is a string") + .to_owned(), + bytes: std::fs::read(scenario_root.join(relative_path)) + .expect("captured evidence is readable"), + }) + }) + .collect::>(); + mutate(&mut manifest, &mut payloads); + let canonical_manifest = json!({ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": {"synthetic": true, "rawPaths": "redacted"}, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": manifest["topology"]["siteCode"], + "rolesObserved": manifest["topology"]["rolesObserved"], + }, + "artifacts": manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .map(|artifact| json!({ + "artifactId": artifact["artifactId"], + "producerRole": artifact["producerRole"], + "producerHostHandle": canonical_distribution_point_host_handle( + artifact["producerHostHandle"].as_str() + ), + "workflowSubject": if artifact["producerRole"] == "distributionPoint" { + Value::Null + } else { + json!({ + "role": artifact["workflowSubjectRole"], + "instanceHandle": canonical_distribution_point_subject_handle( + artifact["workflowSubjectHandle"].as_str() + ), + }) + }, + "sourceId": artifact["sourceId"], + "sourceKind": artifact["sourceKind"], + "sourceVersion": artifact["sourceVersion"], + "originalPath": "REDACTED_DP_SOURCE_ROOT", + "originalBasename": artifact["originalBasename"], + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": artifact["pathFingerprint"], + }, + "rotation": { + "kind": artifact["rotation"]["kind"], + "lineageId": artifact["rotation"]["lineageId"], + }, + "captureState": artifact["captureState"], + "truncated": if artifact["rotation"]["fragmentComplete"] == json!(false) { + json!(false) + } else { + Value::Null + }, + "fragmentComplete": if artifact["rotation"]["fragmentComplete"] == json!(false) { + json!(false) + } else { + Value::Null + }, + "encoding": artifact["encoding"], + "collectionLimit": artifact["collectionLimit"], + "collectedUtc": artifact["collectedUtc"], + "relativePath": if matches!( + artifact["captureState"].as_str(), + Some("captured" | "capped" | "parseFailed") + ) { + json!(canonical_distribution_point_relative_path(artifact)) + } else { + Value::Null + }, + "bytesCopied": payloads + .iter() + .find(|payload| payload.manifest_artifact_id == artifact["artifactId"]) + .map(|payload| payload.bytes.len() as u64) + .unwrap_or(0), + })) + .collect::>(), + }); + let canonical_manifest_json = + serde_json::to_string(&canonical_manifest).expect("canonical manifest serializes"); + assess_server_intake(&canonical_manifest_json, &payloads) + .expect("distribution point fixture is canonical server intake") +} + +fn canonical_distribution_point_relative_path(artifact: &Value) -> String { + let role_segment = match artifact["producerRole"].as_str() { + Some("siteServer") => "site-server", + Some("distributionPoint") => "distribution-point", + _ => panic!("DP fixture has a supported producer role"), + }; + let basename = artifact["originalBasename"] + .as_str() + .expect("DP fixture has a source basename"); + let subject_segment = match artifact["workflowSubjectHandle"].as_str() { + _ if artifact["producerRole"] == "distributionPoint" => "", + Some("safe:dp:lab-dp-01") => "subject-distribution-point/instance-aaaaaaaa/", + Some("safe:dp:lab-dp-02") => "subject-distribution-point/instance-bbbbbbbb/", + _ => "subject-distribution-point/", + }; + let root_segment = match artifact["producerHostHandle"].as_str() { + Some("safe:dp:lab-dp-01") => "root-aaaaaaaa/", + Some("safe:dp:lab-dp-02") => "root-bbbbbbbb/", + _ => "", + }; + let source_id = artifact["sourceId"] + .as_str() + .expect("DP fixture has a source id"); + let rotation_segment = match artifact["rotation"]["kind"].as_str() { + Some("current") => "current", + Some("lo_") => "lo_", + other => panic!("unsupported DP fixture rotation: {other:?}"), + }; + format!( + "evidence/sccm/server/{role_segment}/{source_id}/{subject_segment}{root_segment}{rotation_segment}/{basename}" + ) +} + +fn canonical_distribution_point_host_handle(handle: Option<&str>) -> Value { + match handle { + Some("safe:server:lab-pri-01") => json!("synthetic:host:site-01"), + Some("safe:dp:lab-dp-01") => json!("synthetic:host:mp-01"), + Some("safe:dp:lab-dp-02") => json!("synthetic:host:wsus-01"), + Some("safe:client:lab-client-01") => json!("synthetic:host:site-01"), + None => Value::Null, + Some(other) => panic!("unsupported DP fixture host handle: {other}"), + } +} + +fn canonical_distribution_point_subject_handle(handle: Option<&str>) -> Value { + match handle { + Some("safe:dp:lab-dp-01") => json!("synthetic:subject:dp-01"), + Some("safe:dp:lab-dp-02") => json!("synthetic:subject:dp-02"), + None => Value::Null, + Some(other) => panic!("unsupported DP fixture subject handle: {other}"), + } +} + +fn dp_payload_mut(payloads: &mut [SccmServerArtifactPayload]) -> &mut SccmServerArtifactPayload { + payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == "dp-dist-current") + .expect("fixture contains the DP payload") +} + +fn set_dp_nonphysical_coverage( + manifest: &mut Value, + payloads: &mut Vec, + state: &str, +) { + let artifact = dp_manifest_artifact_mut(manifest); + artifact["captureState"] = json!(state); + artifact["relativePath"] = Value::Null; + artifact["bytesCopied"] = json!(0); + artifact["encoding"] = Value::Null; + artifact["collectionLimit"] = Value::Null; + artifact["collectionDetail"] = Value::Null; + artifact["skipReason"] = Value::Null; + artifact["unsupportedReason"] = Value::Null; + match state { + "accessDenied" => artifact["collectionDetail"] = json!("synthetic permission denial"), + "skipped" => artifact["skipReason"] = json!("optional supplemental source not requested"), + "unsupported" => { + artifact["unsupportedReason"] = json!("no approved server source contract") + } + _ => {} + } + payloads.retain(|payload| payload.manifest_artifact_id != "dp-dist-current"); +} + +fn dp_coverage_assessment(case: &str) -> SccmServerIntakeAssessment { + assess_complete_manifest_and_payloads_after(|manifest, payloads| match case { + "absent" | "accessDenied" | "skipped" | "unsupported" => { + set_dp_nonphysical_coverage(manifest, payloads, case); + } + "capped" => { + let payload = dp_payload_mut(payloads); + payload.bytes.truncate(64); + let artifact = dp_manifest_artifact_mut(manifest); + artifact["captureState"] = json!("capped"); + artifact["bytesCopied"] = json!(64); + artifact["collectionLimit"] = json!({"byteLimit": 64, "limitApplied": true}); + artifact["truncated"] = json!(true); + artifact["fragmentComplete"] = json!(false); + } + "malformed" => { + let payload = dp_payload_mut(payloads); + payload.bytes = b"not a complete CCM logical record".to_vec(); + let bytes_copied = payload.bytes.len() as u64; + dp_manifest_artifact_mut(manifest)["bytesCopied"] = json!(bytes_copied); + } + _ => panic!("declared DP coverage case"), + }) + .unwrap_or_else(|error| panic!("{case} DP coverage is sealed: {error}")) +} + +fn dp_manifest_artifact_mut(manifest: &mut Value) -> &mut Value { + manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "dp-dist-current") + .expect("fixture contains the DP artifact") +} + +fn dp_artifact_index(assessment: &SccmServerIntakeAssessment) -> usize { + assessment + .artifacts + .iter() + .position(|artifact| artifact.artifact_id == "dp-dist-current") + .expect("fixture contains the DP artifact") +} + +fn dp_evidence_index(assessment: &SccmServerIntakeAssessment) -> usize { + assessment + .evidence + .iter() + .position(|evidence| evidence.reference.artifact_id == "dp-dist-current") + .expect("fixture contains the DP evidence") +} + +fn add_dp_peer(assessment: &mut SccmServerIntakeAssessment) { + let artifact_index = dp_artifact_index(assessment); + let mut peer = assessment.artifacts[artifact_index].clone(); + peer.artifact_id = "dp-dist-peer".to_owned(); + peer.rotation_lineage_handle = "synthetic:lineage:dp-dist-peer".to_owned(); + peer.path_fingerprint = "synthetic:path:site-dp-control-peer".to_owned(); + assessment.artifacts.push(peer); + + let evidence_index = dp_evidence_index(assessment); + let mut peer_evidence = assessment.evidence[evidence_index].clone(); + peer_evidence.evidence_id = "dp-dist-peer:1-1".to_owned(); + peer_evidence.reference.artifact_id = "dp-dist-peer".to_owned(); + peer_evidence.reference.entry_id = "dp-dist-peer:1-1".to_owned(); + peer_evidence.reference.line_start = Some(1); + peer_evidence.reference.line_end = Some(1); + assessment.evidence.push(peer_evidence); + + assessment + .coverage + .iter_mut() + .find(|coverage| coverage.source_id == "server-dp-distribution") + .expect("fixture contains DP coverage") + .artifact_ids + .push("dp-dist-peer".to_owned()); +} + +fn assert_dp_sealed_guard_rejection( + assessment: &SccmServerIntakeAssessment, + context: &str, +) -> Value { + let analysis = analyze_distribution_point(assessment); + assert!( + analysis.source_observations.is_empty(), + "{context}: rejected DP evidence must not become a source observation" + ); + assert_eq!(analysis.coverage_gaps.len(), 1, "{context}"); + let gap = &analysis.coverage_gaps[0]; + assert_eq!(gap.source_id, "server-dp-distribution", "{context}"); + assert_eq!(gap.producer_role, Some(SccmRole::SiteServer), "{context}"); + assert_eq!( + gap.workflow_subject_role, + Some(SccmRole::DistributionPoint), + "{context}" + ); + assert_eq!(gap.state, Some(SccmCoverageState::Captured), "{context}"); + assert_eq!(gap.artifact_ids, vec!["dp-dist-current"], "{context}"); + assert_eq!( + gap.reason, + "Captured Distribution Point evidence is incomplete or outside the supported intake profile.", + "{context}" + ); + assert_eq!( + artifact_request_contracts(&analysis.artifact_requests), + expected_site_server_artifact_requests(), + "{context}" + ); + assert!(!analysis.cross_side_correlation_performed, "{context}"); + serde_json::to_value(analysis).expect("coverage-only analysis serializes") +} + +fn assert_dp_intake_authority_invalid( + assessment: &SccmServerIntakeAssessment, + context: &str, +) -> Value { + let analysis = analyze_distribution_point(assessment); + assert!( + analysis.source_observations.is_empty(), + "{context}: unsealed intake must not export a source observation" + ); + assert_eq!(analysis.coverage_gaps.len(), 1, "{context}"); + let gap = &analysis.coverage_gaps[0]; + assert_eq!(gap.source_id, "server-dp-distribution", "{context}"); + assert_eq!(gap.producer_role, None, "{context}"); + assert_eq!( + gap.workflow_subject_role, + Some(SccmRole::DistributionPoint), + "{context}" + ); + assert_eq!(gap.state, Some(SccmCoverageState::ParseFailed), "{context}"); + assert!(gap.artifact_ids.is_empty(), "{context}"); + assert_eq!( + gap.reason, "Canonical server intake authority could not be verified.", + "{context}" + ); + assert_eq!( + artifact_request_contracts(&analysis.artifact_requests), + expected_all_dp_profile_artifact_requests(), + "{context}" + ); + assert!(!analysis.cross_side_correlation_performed, "{context}"); + serde_json::to_value(analysis).expect("authority-invalid analysis serializes") +} + +#[test] +fn distribution_point_adapter_projects_only_canonical_intake_observations_deterministically() { + let assessment = load_assessment("complete-multi-role"); + + let analysis = analyze_distribution_point(&assessment); + + assert!(!analysis.cross_side_correlation_performed); + assert_eq!( + analysis.schema_version, + SCCM_DISTRIBUTION_POINT_ANALYSIS_SCHEMA_VERSION + ); + assert_eq!( + analysis.profile.id, + SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_ID + ); + assert_eq!( + analysis.profile.version, + SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_VERSION + ); + assert_eq!(analysis.profile.stability, "experimental"); + assert!(analysis.coverage_gaps.is_empty()); + assert!(analysis.artifact_requests.is_empty()); + assert_eq!(analysis.source_observations.len(), 1); + + let observation = &analysis.source_observations[0]; + assert_eq!(observation.artifact_id, "dp-dist-current"); + assert_eq!(observation.producer_role, SccmRole::SiteServer); + assert_eq!( + observation.producer_host_handle.as_deref(), + Some("synthetic:host:site-01") + ); + assert_eq!( + observation.workflow_subject_role, + Some(SccmRole::DistributionPoint) + ); + assert_eq!( + observation.workflow_subject_handle.as_deref(), + Some("synthetic:subject:dp-01") + ); + assert_eq!(observation.source_id, "server-dp-distribution"); + assert_eq!(observation.rotation, Some(SccmRotation::Current)); + assert_eq!(observation.rotation_lineage_handle, "dp-dist-lab"); + assert_eq!( + observation.timestamp.ordering_state, + SccmTimeOrderingState::NormalizedUtc + ); + + let mut reordered = assessment.clone(); + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + reordered.topology.roles_observed.reverse(); + assert_eq!( + serde_json::to_value(&analysis).expect("analysis serializes"), + serde_json::to_value(analyze_distribution_point(&reordered)) + .expect("reordered analysis serializes") + ); +} + +#[test] +fn healthy_package_reduces_a_sealed_role_local_transaction() { + let assessment = load_distribution_point_assessment("healthy-package"); + let bounded = analyze_distribution_point(&assessment); + assert_eq!(bounded.source_observations.len(), 5); + + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("healthy canonical intake must enter the DP semantic reducer"); + + assert!(!analysis.cross_side_correlation_performed); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!(analysis.transactions[0].key.package_id, "LAB00001"); + assert_eq!(analysis.transactions[0].key.content_id, "content-alpha"); + assert_eq!(analysis.transactions[0].key.content_version, 1); + assert_eq!( + analysis.transactions[0].key.topology_site_handle, + assessment.topology.site_handle + ); + assert_eq!( + analysis.transactions[0] + .key + .distribution_point_handle + .as_str(), + "synthetic:subject:dp-01" + ); + assert_eq!(analysis.transactions[0].evidence.len(), 5); +} + +#[test] +fn newer_intake_valid_source_version_stays_outside_the_exact_semantic_profile() { + let assessment = load_distribution_point_assessment_after("healthy-package", |manifest, _| { + for artifact in manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + { + artifact["sourceVersion"] = json!("5.00.TEST.0002"); + } + }); + assert!(assessment.artifacts.iter().all(|artifact| { + artifact.profile_eligible && artifact.source_version.as_deref() == Some("5.00.TEST.0002") + })); + assert_eq!( + analyze_distribution_point(&assessment) + .source_observations + .len(), + 5 + ); + + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("newer source version remains canonical intake"); + + assert!( + analysis.transactions.is_empty(), + "the exact .0001 profile must not claim success for .0002 evidence" + ); + assert_eq!(analysis.coverage_gaps.len(), 2); + assert!(analysis + .coverage_gaps + .iter() + .all(|gap| gap.state == Some(SccmCoverageState::Captured))); + assert_eq!( + artifact_request_contracts(&analysis.artifact_requests), + expected_all_dp_profile_artifact_requests() + ); +} + +#[test] +fn healthy_package_requires_profile_site_token_to_match_sealed_topology() { + let mut mutated_records = 0usize; + let assessment = load_distribution_point_assessment_after("healthy-package", |_, payloads| { + for payload in payloads { + let content = + std::str::from_utf8(&payload.bytes).expect("synthetic DP evidence is UTF-8"); + assert!( + content.contains("SiteCode=LAB"), + "every healthy source must carry the profile site token" + ); + mutated_records = + mutated_records.saturating_add(content.matches("SiteCode=LAB").count()); + payload.bytes = content.replace("SiteCode=LAB", "SiteCode=ABC").into_bytes(); + } + }); + assert_eq!( + mutated_records, 5, + "the required healthy phase chain is mutated" + ); + assert_eq!(assessment.topology.site_handle, "synthetic:site:lab"); + + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("site-token mismatch remains sealed evidence, not forged authority"); + + assert!( + analysis.transactions.is_empty(), + "valid-looking evidence for another site cannot become a healthy transaction" + ); +} + +#[test] +fn semantic_analysis_preserves_conservative_source_coverage_and_bounded_requests() { + for (case, expected_state) in [ + ("absent", SccmCoverageState::Absent), + ("accessDenied", SccmCoverageState::AccessDenied), + ("capped", SccmCoverageState::Capped), + ("skipped", SccmCoverageState::Skipped), + ("unsupported", SccmCoverageState::Unsupported), + ("malformed", SccmCoverageState::ParseFailed), + ] { + let assessment = dp_coverage_assessment(case); + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .unwrap_or_else(|error| panic!("{case} coverage remains analyzable: {error}")); + + assert!(analysis.transactions.is_empty(), "{case}"); + assert_eq!(analysis.coverage_gaps.len(), 1, "{case}"); + assert_eq!( + analysis.coverage_gaps[0].state, + Some(expected_state), + "{case}" + ); + assert_eq!( + artifact_request_contracts(&analysis.artifact_requests), + expected_site_server_artifact_requests(), + "{case}" + ); + + let repeated = + analyze_distribution_point_content_from_server_intake(&dp_coverage_assessment(case)) + .expect("repeated coverage remains analyzable"); + assert_eq!( + serde_json::to_value(&analysis).expect("analysis serializes"), + serde_json::to_value(repeated).expect("repeated analysis serializes"), + "{case} output is deterministic" + ); + } +} + +#[test] +fn incomplete_and_unknown_evidence_remain_explicit_bounded_results() { + let incomplete = + load_distribution_point_assessment_after("healthy-package", |manifest, payloads| { + let provider = payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == "dp-healthy-03-provider") + .expect("fixture contains provider payload"); + let content = + std::str::from_utf8(&provider.bytes).expect("synthetic provider evidence is UTF-8"); + let retained = content + .lines() + .filter(|line| !line.contains("Phase=makeAvailable")) + .collect::>() + .join("\n") + + "\n"; + provider.bytes = retained.into_bytes(); + manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "dp-healthy-03-provider") + .expect("fixture contains provider artifact")["bytesCopied"] = + json!(provider.bytes.len() as u64); + }); + let incomplete_analysis = analyze_distribution_point_content_from_server_intake(&incomplete) + .expect("incomplete semantic evidence remains analyzable"); + assert_eq!(incomplete_analysis.transactions.len(), 1); + assert_eq!( + format!("{:?}", incomplete_analysis.transactions[0].state), + "Incomplete" + ); + assert!(incomplete_analysis.coverage_gaps.is_empty()); + assert_eq!( + artifact_request_contracts(&incomplete_analysis.artifact_requests), + expected_dp_artifact_requests() + ); + + let unknown_profile = + load_distribution_point_assessment_after("healthy-package", |_, payloads| { + for payload in payloads { + let content = + std::str::from_utf8(&payload.bytes).expect("synthetic DP evidence is UTF-8"); + payload.bytes = content + .replace("Disposition=succeeded", "Disposition=unknown") + .into_bytes(); + } + }); + let unknown_analysis = analyze_distribution_point_content_from_server_intake(&unknown_profile) + .expect("unknown semantic profile remains analyzable"); + assert!(unknown_analysis.transactions.is_empty()); + assert_eq!(unknown_analysis.coverage_gaps.len(), 2); + assert!(unknown_analysis + .coverage_gaps + .iter() + .all(|gap| gap.state == Some(SccmCoverageState::Captured))); + assert_eq!( + artifact_request_contracts(&unknown_analysis.artifact_requests), + expected_all_dp_profile_artifact_requests() + ); +} + +#[test] +fn healthy_transaction_cannot_hide_a_sealed_coverage_gap_or_keep_high_confidence() { + let assessment = load_distribution_point_assessment_after("healthy-package", |manifest, _| { + manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .push(json!({ + "artifactId": "dp-distribution-absent-candidate", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST.0001", + "originalBasename": "distmgr.log", + "pathFingerprint": "synthetic:path:dp-default", + "rotation": {"kind": "current", "lineageId": "dp-distribution-default"}, + "captureState": "absent", + "encoding": null, + "collectionLimit": null, + "collectedUtc": "2026-07-30T12:20:00Z", + "relativePath": null, + "bytesCopied": 0 + })); + }); + + let analysis = analyze_distribution_point_content_from_server_intake(&assessment) + .expect("mixed healthy and missing coverage remains analyzable"); + + assert_eq!(analysis.transactions.len(), 1); + assert_eq!(analysis.coverage_gaps.len(), 1); + assert_eq!( + analysis.coverage_gaps[0].state, + Some(SccmCoverageState::Absent) + ); + assert_eq!( + artifact_request_contracts(&analysis.artifact_requests), + expected_site_server_artifact_requests() + ); + assert_eq!( + analysis.transactions[0].confidence, + SccmDistributionPointContentConfidence::Medium + ); +} + +#[test] +fn sealed_intake_without_dp_source_version_reaches_profile_eligibility_guard() { + let assessment = assess_complete_manifest_after(|manifest| { + dp_manifest_artifact_mut(manifest) + .as_object_mut() + .expect("DP artifact is an object") + .remove("sourceVersion"); + }) + .expect("missing source version is retained as sealed, profile-ineligible intake"); + let artifact = &assessment.artifacts[dp_artifact_index(&assessment)]; + + assert_eq!(artifact.source_version, None); + assert!(!artifact.profile_eligible); + assert!(artifact.parser_eligible); + assert_eq!( + artifact.workflow_subject_handle.as_deref(), + Some("synthetic:subject:dp-01") + ); + assert!(assessment + .topology + .roles_observed + .contains(&SccmRole::DistributionPoint)); + assert_dp_sealed_guard_rejection(&assessment, "missing DP source version"); +} + +#[test] +fn sealed_intake_without_dp_subject_handle_reaches_subject_congruence_guard() { + let assessment = assess_complete_manifest_after(|manifest| { + let subject = dp_manifest_artifact_mut(manifest)["workflowSubject"] + .as_object_mut() + .expect("workflow subject is an object"); + subject.remove("instanceHandle"); + subject.insert( + "basis".to_owned(), + Value::String("incidentScopeOnly".to_owned()), + ); + }) + .expect("missing subject handle is retained as sealed intake"); + let artifact = &assessment.artifacts[dp_artifact_index(&assessment)]; + + assert!(artifact.profile_eligible); + assert!(artifact.parser_eligible); + assert_eq!( + artifact.workflow_subject_role, + Some(SccmRole::DistributionPoint) + ); + assert_eq!(artifact.workflow_subject_handle, None); + assert!(assessment + .topology + .roles_observed + .contains(&SccmRole::DistributionPoint)); + assert_dp_sealed_guard_rejection(&assessment, "missing DP subject handle"); +} + +#[test] +fn sealed_intake_without_observed_dp_role_reaches_topology_congruence_guard() { + let assessment = assess_complete_manifest_after(|manifest| { + manifest["topology"]["rolesObserved"] + .as_array_mut() + .expect("observed roles are an array") + .retain(|role| role.as_str() != Some("distributionPoint")); + }) + .expect("unobserved workflow-subject role is retained as sealed intake"); + let artifact = &assessment.artifacts[dp_artifact_index(&assessment)]; + + assert!(artifact.profile_eligible); + assert!(artifact.parser_eligible); + assert_eq!( + artifact.workflow_subject_handle.as_deref(), + Some("synthetic:subject:dp-01") + ); + assert!(!assessment + .topology + .roles_observed + .contains(&SccmRole::DistributionPoint)); + assert_dp_sealed_guard_rejection(&assessment, "unobserved DP workflow-subject role"); +} + +#[test] +fn dp_subject_role_mismatch_is_rejected_before_intake_sealing() { + let result = assess_complete_manifest_after(|manifest| { + dp_manifest_artifact_mut(manifest)["workflowSubject"]["role"] = + Value::String("managementPoint".to_owned()); + }); + + assert!( + matches!(result, Err(SccmServerIntakeError::InvalidArtifact)), + "role/source mismatch must not produce a sealed assessment: {result:?}" + ); +} + +#[test] +fn dp_rotation_mismatch_is_rejected_before_intake_sealing() { + let result = assess_complete_manifest_after(|manifest| { + dp_manifest_artifact_mut(manifest)["rotation"]["kind"] = Value::String("lo_".to_owned()); + }); + + assert!( + matches!(result, Err(SccmServerIntakeError::InvalidArtifact)), + "basename/rotation mismatch must not produce a sealed assessment: {result:?}" + ); +} + +#[test] +fn post_intake_topology_and_coverage_handle_mutations_fail_sealed_authority_closed() { + let assessment = load_assessment("complete-multi-role"); + let artifact_index = dp_artifact_index(&assessment); + let coverage_index = assessment + .coverage + .iter() + .position(|coverage| coverage.source_id == "server-dp-distribution") + .expect("fixture contains DP coverage"); + + let mut coordinated_producer = assessment.clone(); + coordinated_producer.artifacts[artifact_index].producer_host_handle = + Some("synthetic:host:forged-dp-producer".to_owned()); + coordinated_producer.coverage[coverage_index].producer_host_handle = + Some("synthetic:host:forged-dp-producer".to_owned()); + let producer_output = assert_dp_intake_authority_invalid( + &coordinated_producer, + "coordinated producer-host mutation", + ); + assert!(!producer_output + .to_string() + .contains("synthetic:host:forged-dp-producer")); + + let mut coordinated_subject = assessment.clone(); + coordinated_subject.artifacts[artifact_index].workflow_subject_handle = + Some("synthetic:subject:forged-dp".to_owned()); + coordinated_subject.coverage[coverage_index].workflow_subject_handle = + Some("synthetic:subject:forged-dp".to_owned()); + let subject_output = assert_dp_intake_authority_invalid( + &coordinated_subject, + "coordinated workflow-subject mutation", + ); + assert!(!subject_output + .to_string() + .contains("synthetic:subject:forged-dp")); + + let mut changed_topology = assessment; + changed_topology.topology.capture_host_handle = "synthetic:host:forged-capture".to_owned(); + changed_topology.topology.site_handle = "synthetic:site:forged".to_owned(); + let topology_output = + assert_dp_intake_authority_invalid(&changed_topology, "topology handle mutation"); + let topology_json = topology_output.to_string(); + assert!(!topology_json.contains("synthetic:host:forged-capture")); + assert!(!topology_json.contains("synthetic:site:forged")); +} + +#[test] +fn post_intake_coverage_and_evidence_shape_mutations_fail_sealed_authority_closed() { + let assessment = load_assessment("complete-multi-role"); + let evidence_index = dp_evidence_index(&assessment); + + let mut missing_coverage = assessment.clone(); + missing_coverage + .coverage + .retain(|coverage| coverage.source_id != "server-dp-distribution"); + + let mut duplicate_coverage = assessment.clone(); + let dp_coverage = duplicate_coverage + .coverage + .iter() + .find(|coverage| coverage.source_id == "server-dp-distribution") + .expect("fixture contains DP coverage") + .clone(); + duplicate_coverage.coverage.push(dp_coverage); + + let mut holey_coverage = assessment.clone(); + holey_coverage + .coverage + .iter_mut() + .find(|coverage| coverage.source_id == "server-dp-distribution") + .expect("fixture contains DP coverage") + .artifact_ids + .push("dp-dist-undeclared".to_owned()); + + let mut missing_evidence = assessment.clone(); + missing_evidence.evidence.remove(evidence_index); + + let mut duplicate_evidence = assessment.clone(); + duplicate_evidence + .evidence + .push(duplicate_evidence.evidence[evidence_index].clone()); + + let mut holey_evidence = assessment.clone(); + holey_evidence.evidence[evidence_index].evidence_id = "dp-dist-current:1-1".to_owned(); + holey_evidence.evidence[evidence_index].reference.entry_id = "dp-dist-current:1-1".to_owned(); + holey_evidence.evidence[evidence_index].reference.line_start = Some(1); + holey_evidence.evidence[evidence_index].reference.line_end = Some(1); + let mut after_hole = holey_evidence.evidence[evidence_index].clone(); + after_hole.evidence_id = "dp-dist-current:3-3".to_owned(); + after_hole.reference.entry_id = "dp-dist-current:3-3".to_owned(); + after_hole.reference.line_start = Some(3); + after_hole.reference.line_end = Some(3); + holey_evidence.evidence.push(after_hole); + + let mut mismatched_evidence = assessment; + mismatched_evidence.evidence[evidence_index].role = SccmRole::DistributionPoint; + + for (context, mutated) in [ + ("missing coverage", missing_coverage), + ("duplicate coverage", duplicate_coverage), + ("holey coverage", holey_coverage), + ("missing evidence", missing_evidence), + ("duplicate evidence", duplicate_evidence), + ("holey evidence", holey_evidence), + ("mismatched evidence", mismatched_evidence), + ] { + assert_dp_intake_authority_invalid(&mutated, context); + } +} + +#[test] +fn absent_dp_candidate_is_coverage_not_a_role_diagnosis() { + let assessment = load_assessment("absent-dp"); + + let analysis = analyze_distribution_point(&assessment); + + assert!(!analysis.cross_side_correlation_performed); + assert!(analysis.source_observations.is_empty()); + assert_eq!(analysis.coverage_gaps.len(), 1); + assert_eq!( + analysis.coverage_gaps[0].state, + Some(SccmCoverageState::Absent) + ); + assert_eq!( + analysis.coverage_gaps[0].source_id, + "server-dp-distribution" + ); + assert_eq!( + analysis.coverage_gaps[0].reason, + "Distribution Point source coverage is absent; recollect the declared source without changing its state." + ); + assert_eq!( + artifact_request_contracts(&analysis.artifact_requests), + expected_site_server_artifact_requests() + ); + serde_json::to_value(&analysis).expect("coverage-only analysis serializes"); +} + +#[test] +fn no_declared_dp_source_requests_both_bounded_sides_without_correlation() { + let assessment = load_assessment("collision-same-basename-configured-roots"); + + let analysis = analyze_distribution_point(&assessment); + + assert!(analysis.source_observations.is_empty()); + assert_eq!(analysis.coverage_gaps.len(), 1); + assert_eq!(analysis.coverage_gaps[0].producer_role, None); + assert_eq!( + artifact_request_contracts(&analysis.artifact_requests), + expected_all_dp_profile_artifact_requests() + ); + assert!(!analysis.cross_side_correlation_performed); +} + +#[test] +fn post_intake_duplicate_dp_artifact_identity_is_authority_quarantined_deterministically() { + let mut assessment = load_assessment("complete-multi-role"); + let artifact_index = dp_artifact_index(&assessment); + let mut duplicate = assessment.artifacts[artifact_index].clone(); + duplicate.producer_host_handle = Some("synthetic:host:site-02".to_owned()); + duplicate.path_fingerprint = "synthetic:path:site-dp-control-02".to_owned(); + assessment.artifacts.push(duplicate); + + let first = assert_dp_intake_authority_invalid(&assessment, "duplicate DP artifact identity"); + assessment.artifacts.reverse(); + let reversed = + assert_dp_intake_authority_invalid(&assessment, "reordered duplicate DP artifact identity"); + + assert_eq!(first, reversed); +} + +#[test] +fn post_intake_evidence_range_mutations_are_authority_quarantined() { + let assessment = load_assessment("complete-multi-role"); + let evidence_index = dp_evidence_index(&assessment); + + let mut missing_range = assessment.clone(); + missing_range.evidence[evidence_index].reference.line_start = None; + assert_dp_intake_authority_invalid(&missing_range, "missing evidence range start"); + + let mut duplicate = assessment.clone(); + duplicate + .evidence + .push(duplicate.evidence[evidence_index].clone()); + assert_dp_intake_authority_invalid(&duplicate, "duplicate evidence range"); + + let mut overlap = assessment.clone(); + let mut overlapping = overlap.evidence[evidence_index].clone(); + overlapping.evidence_id = "dp-dist-current:1-2".to_owned(); + overlapping.reference.entry_id = "dp-dist-current:1-2".to_owned(); + overlapping.reference.line_end = Some(2); + overlap.evidence.push(overlapping); + assert_dp_intake_authority_invalid(&overlap, "overlapping evidence range"); +} + +#[test] +fn post_intake_physical_line_hole_is_authority_quarantined() { + let mut assessment = load_assessment("complete-multi-role"); + let evidence_index = dp_evidence_index(&assessment); + assessment.evidence[evidence_index].evidence_id = "dp-dist-current:1-1".to_owned(); + assessment.evidence[evidence_index].reference.entry_id = "dp-dist-current:1-1".to_owned(); + assessment.evidence[evidence_index].reference.line_start = Some(1); + assessment.evidence[evidence_index].reference.line_end = Some(1); + + let mut after_hole = assessment.evidence[evidence_index].clone(); + after_hole.evidence_id = "dp-dist-current:3-3".to_owned(); + after_hole.reference.entry_id = "dp-dist-current:3-3".to_owned(); + after_hole.reference.line_start = Some(3); + after_hole.reference.line_end = Some(3); + assessment.evidence.push(after_hole); + + assert_dp_intake_authority_invalid(&assessment, "physical evidence line hole"); +} + +#[test] +fn post_intake_peer_mutations_fail_sealed_authority_closed() { + for defect in [ + "profile-ineligible", + "parser-ineligible", + "incomplete-fragment", + "invalid-evidence", + ] { + let mut assessment = load_assessment("complete-multi-role"); + add_dp_peer(&mut assessment); + let peer_index = assessment + .artifacts + .iter() + .position(|artifact| artifact.artifact_id == "dp-dist-peer") + .expect("peer artifact exists"); + + match defect { + "profile-ineligible" => assessment.artifacts[peer_index].profile_eligible = false, + "parser-ineligible" => assessment.artifacts[peer_index].parser_eligible = false, + "incomplete-fragment" => { + assessment.artifacts[peer_index].fragment_complete = Some(false) + } + "invalid-evidence" => { + assessment + .evidence + .iter_mut() + .find(|evidence| evidence.reference.artifact_id == "dp-dist-peer") + .expect("peer evidence exists") + .reference + .line_start = None + } + _ => unreachable!("test defect is declared above"), + } + + let expected = assert_dp_intake_authority_invalid(&assessment, defect); + assessment.artifacts.reverse(); + assessment.evidence.reverse(); + assessment.coverage.reverse(); + for coverage in &mut assessment.coverage { + coverage.artifact_ids.reverse(); + } + assert_eq!( + expected, + assert_dp_intake_authority_invalid(&assessment, defect), + "{defect} output must be deterministic" + ); + } +} + +#[test] +fn post_intake_missing_exact_coverage_membership_is_authority_quarantined() { + // Canonical intake derives coverage membership from normalized artifacts. + // A missing membership is therefore a post-intake integrity mutation, not + // a separately reachable adapter-predicate state. + let mut assessment = load_assessment("complete-multi-role"); + assessment + .coverage + .retain(|coverage| coverage.source_id != "server-dp-distribution"); + + assert_dp_intake_authority_invalid(&assessment, "missing exact coverage membership"); +} + +#[test] +fn post_intake_role_topology_profile_and_rotation_mutations_are_authority_quarantined() { + let assessment = load_assessment("complete-multi-role"); + let artifact_index = dp_artifact_index(&assessment); + + let mut wrong_role = assessment.clone(); + wrong_role.artifacts[artifact_index].workflow_subject_role = Some(SccmRole::ManagementPoint); + assert_dp_intake_authority_invalid(&wrong_role, "workflow-subject role mutation"); + + let mut missing_topology = assessment.clone(); + missing_topology + .topology + .roles_observed + .retain(|role| role != &SccmRole::DistributionPoint); + assert_dp_intake_authority_invalid(&missing_topology, "topology role mutation"); + + let mut ineligible_profile = assessment.clone(); + ineligible_profile.artifacts[artifact_index].profile_eligible = false; + assert_dp_intake_authority_invalid(&ineligible_profile, "profile eligibility mutation"); + + let mut wrong_rotation = assessment.clone(); + wrong_rotation.artifacts[artifact_index].rotation = Some(SccmRotation::LoUnderscore); + assert_dp_intake_authority_invalid(&wrong_rotation, "rotation mutation"); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs new file mode 100644 index 000000000..f8404931c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs @@ -0,0 +1,4177 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; + +use chrono::DateTime; +use cmtraceopen_parser::sccm::{ + classify_artifact_name, normalize_ccm_artifact, normalize_key, SccmArtifact, + SccmArtifactFamily, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmKeyConfidence, + SccmRole, SccmRotation, SccmTimeOrderingState, SccmTimestamp, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const SCENARIOS: &[&str] = &[ + "absent-remote-source", + "backlog-retry", + "clock-offset-unknown", + "generic-site-token", + "healthy-link", + "incomplete", + "receiver-processing-failure", + "recovery", + "rotation-boundary", + "sender-failure", + "topology-mismatch", +]; + +const STATE_CHAIN: &[&str] = &[ + "initiate", + "queueOrSerialize", + "send", + "receive", + "process", + "acknowledge", + "healthyOrTerminal", +]; + +const EXACT_PROFILE: &str = "hierarchy-server-5.00.test-v1"; +const EXACT_SOURCE_VERSION: &str = "5.00.TEST.0001"; +const MAX_OPAQUE_ID_BYTES: usize = 128; +const MAX_SAFE_PATH_BYTES: usize = 512; +const MAX_SAFE_PATH_SEGMENT_BYTES: usize = 128; + +fn corpus_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/hierarchy_and_replication") +} + +fn read_json(scenario: &str, filename: &str) -> Result { + let path = corpus_root().join(scenario).join(filename); + let contents = std::fs::read_to_string(&path) + .map_err(|error| format!("{} is readable: {error}", path.display()))?; + serde_json::from_str(&contents) + .map_err(|error| format!("{} contains valid JSON: {error}", path.display())) +} + +fn actual_scenarios() -> Result, String> { + let root = corpus_root(); + let mut scenarios = std::fs::read_dir(&root) + .map_err(|error| format!("{} is readable: {error}", root.display()))? + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then(|| { + path.file_name() + .expect("scenario directory has a name") + .to_string_lossy() + .into_owned() + }) + }) + .collect::>(); + scenarios.sort(); + Ok(scenarios) +} + +fn required_string<'a>(value: &'a Value, field: &str, context: &str) -> Result<&'a str, String> { + value[field] + .as_str() + .ok_or_else(|| format!("{context}.{field} must be a string")) +} + +fn safe_opaque_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_OPAQUE_ID_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn safe_prefixed_opaque_id(value: &str, prefix: &str) -> bool { + value.strip_prefix(prefix).is_some_and(safe_opaque_id) +} + +fn safe_segmented_path(value: &str, prefix: &str) -> bool { + value.strip_prefix(prefix).is_some_and(|suffix| { + value.len() <= MAX_SAFE_PATH_BYTES + && !suffix.is_empty() + && !suffix.contains('\\') + && suffix.split('/').all(|segment| { + !segment.is_empty() + && segment.len() <= MAX_SAFE_PATH_SEGMENT_BYTES + && !matches!(segment, "." | "..") + && segment.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-') + }) + }) + }) +} + +fn safe_server_handle(value: &str) -> bool { + value + .strip_prefix("safe:server:") + .is_some_and(safe_opaque_id) +} + +#[derive(Clone, Copy)] +enum CandidateProvenanceDomain { + ProducerHost, + PathFingerprint, +} + +impl CandidateProvenanceDomain { + const fn label(self) -> &'static str { + match self { + Self::ProducerHost => "producer-host", + Self::PathFingerprint => "path-fingerprint", + } + } +} + +fn candidate_provenance_token(domain: CandidateProvenanceDomain, value: &str) -> String { + let domain = domain.label(); + let mut digest = Sha256::new(); + digest.update(b"cmtraceopen:sccm-hierarchy-provenance:v1\0"); + digest.update(domain.as_bytes()); + digest.update(b"\0"); + digest.update(value.as_bytes()); + let mut token = format!("sccm-provenance:v1:{domain}:sha256:"); + for byte in digest.finalize() { + let _ = write!(&mut token, "{byte:02x}"); + } + token +} + +fn artifact_path_matches_basename(artifact: &Value, field: &str, prefix: &str) -> bool { + artifact["originalBasename"] + .as_str() + .zip(artifact[field].as_str()) + .is_some_and(|(basename, path)| { + safe_segmented_path(path, prefix) && path.rsplit('/').next() == Some(basename) + }) +} + +fn coverage_state(value: &str) -> Option { + match value { + "captured" => Some(SccmCoverageState::Captured), + "absent" => Some(SccmCoverageState::Absent), + "accessDenied" => Some(SccmCoverageState::AccessDenied), + "capped" => Some(SccmCoverageState::Capped), + "skipped" => Some(SccmCoverageState::Skipped), + "unsupported" => Some(SccmCoverageState::Unsupported), + "parseFailed" => Some(SccmCoverageState::ParseFailed), + _ => None, + } +} + +fn rotation(value: &Value) -> Option { + match value["kind"].as_str()? { + "current" if value.get("value").is_none() => Some(SccmRotation::Current), + "loUnderscore" if value.get("value").is_none() => Some(SccmRotation::LoUnderscore), + "numbered" => value["value"] + .as_u64() + .and_then(|number| u32::try_from(number).ok()) + .map(SccmRotation::Numbered), + "timestamped" => value["value"] + .as_str() + .map(str::to_owned) + .map(SccmRotation::Timestamped), + _ => None, + } +} + +fn parse_fixture_fields(message: &str) -> Result, String> { + let message = message + .strip_prefix("[sccm-public-message-v1] ") + .ok_or_else(|| "record lacks the public SCCM projection".to_owned())?; + let mut segments = message.split(';').map(str::trim); + if segments.next() != Some("SYNTHETIC FIXTURE") { + return Err("record lacks the semantic synthetic marker".to_owned()); + } + let allowed = [ + "Phase", + "Disposition", + "Terminal", + "MessageId", + "LinkId", + "OriginSite", + "TargetSite", + "ProfileId", + ]; + let mut fields = BTreeMap::new(); + for segment in segments { + let (name, value) = segment + .split_once('=') + .ok_or_else(|| format!("fixture field is not Name=Value: {segment}"))?; + if !allowed.contains(&name) || value.is_empty() { + return Err(format!("unsupported or empty fixture field {name}")); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(format!("fixture field {name} contains unsupported syntax")); + } + let shared_key_is_exact = |kind| { + let key = normalize_key(kind, value); + key.confidence == SccmKeyConfidence::Exact && key.normalized == value + }; + let field_is_profile_valid = match name { + "MessageId" | "LinkId" => shared_key_is_exact(SccmCorrelationKeyKind::ContentId), + "OriginSite" | "TargetSite" => shared_key_is_exact(SccmCorrelationKeyKind::SiteCode), + "ProfileId" => value == EXACT_PROFILE, + "Phase" => STATE_CHAIN.contains(&value), + "Disposition" => matches!(value, "succeeded" | "failed" | "retrying"), + "Terminal" => matches!(value, "true" | "false"), + _ => false, + }; + if !field_is_profile_valid { + return Err(format!("fixture field {name} is outside the exact profile")); + } + if fields.insert(name.to_owned(), value.to_owned()).is_some() { + return Err(format!("duplicate fixture field {name}")); + } + } + if let (Some(disposition), Some(terminal)) = (fields.get("Disposition"), fields.get("Terminal")) + { + let terminal = terminal == "true"; + if !observation_disposition_is_coherent(disposition, terminal) { + return Err("disposition and terminal fields are incoherent".to_owned()); + } + } + Ok(fields) +} + +fn exact_source_tuple(source_id: &str, basename: &str, direction: &str, role: &str) -> bool { + role == "siteServer" + && matches!( + (source_id, basename, direction), + ("server-hierarchy-control", "replmgr.log", "origin") + | ("server-hierarchy-control", "rcmctrl.log", "target") + | ( + "server-hierarchy-transfer", + "sender.log" | "sender.lo_", + "origin" + ) + | ("server-hierarchy-transfer", "despool.log", "target") + ) +} + +fn artifact_has_exact_source_tuple(artifact: &Value) -> bool { + artifact["sourceId"] + .as_str() + .zip(artifact["originalBasename"].as_str()) + .zip(artifact["direction"].as_str()) + .zip(artifact["producerRole"].as_str()) + .is_some_and(|(((source_id, basename), direction), role)| { + exact_source_tuple(source_id, basename, direction, role) + }) +} + +fn artifact_has_exact_public_provenance(artifact: &Value) -> bool { + artifact["producerHostHandle"] + .as_str() + .is_some_and(safe_server_handle) + && artifact_path_matches_basename(artifact, "sanitizedSourcePath", "SYNTHETIC://") + && artifact["pathFingerprint"] + .as_str() + .is_some_and(|value| safe_prefixed_opaque_id(value, "synthetic:")) + && artifact["sourceVersion"].as_str() == Some(EXACT_SOURCE_VERSION) +} + +fn artifact_has_canonical_basename_rotation(artifact: &Value) -> bool { + matches!( + ( + artifact["originalBasename"].as_str(), + artifact["rotation"]["kind"].as_str(), + artifact["rotation"].get("value"), + ), + ( + Some("replmgr.log" | "sender.log" | "despool.log" | "rcmctrl.log"), + Some("current"), + None, + ) | (Some("sender.lo_"), Some("loUnderscore"), None) + ) +} + +fn target_host_for_site<'a>(manifest: &'a Value, site: &str) -> Option<&'a str> { + let hosts = std::iter::once(( + manifest["topology"]["targetSiteCode"].as_str(), + manifest["topology"]["targetHostHandle"].as_str(), + )) + .chain( + manifest["topology"]["additionalTargets"] + .as_array() + .into_iter() + .flatten() + .map(|target| (target["siteCode"].as_str(), target["hostHandle"].as_str())), + ) + .filter_map(|(candidate_site, host)| (candidate_site == Some(site)).then_some(host).flatten()) + .collect::>(); + (hosts.len() == 1) + .then(|| hosts.into_iter().next()) + .flatten() +} + +fn artifact_matches_topology(manifest: &Value, artifact: &Value) -> bool { + match ( + artifact["direction"].as_str(), + artifact["producerHostHandle"].as_str(), + ) { + (Some("origin"), Some(host)) => { + manifest["topology"]["originHostHandle"].as_str() == Some(host) + } + (Some("target"), Some(host)) => { + std::iter::once(manifest["topology"]["targetHostHandle"].as_str()) + .chain( + manifest["topology"]["additionalTargets"] + .as_array() + .into_iter() + .flatten() + .map(|target| target["hostHandle"].as_str()), + ) + .flatten() + .any(|target_host| target_host == host) + } + _ => false, + } +} + +fn record_matches_topology( + manifest: &Value, + artifact: &Value, + fields: &BTreeMap, +) -> bool { + let Some(origin_site) = fields.get("OriginSite").map(String::as_str) else { + return false; + }; + let Some(target_site) = fields.get("TargetSite").map(String::as_str) else { + return false; + }; + if manifest["topology"]["originSiteCode"].as_str() != Some(origin_site) { + return false; + } + match artifact["direction"].as_str() { + Some("origin") => { + artifact_matches_topology(manifest, artifact) + && target_host_for_site(manifest, target_site).is_some() + } + Some("target") => artifact["producerHostHandle"] + .as_str() + .zip(target_host_for_site(manifest, target_site)) + .is_some_and(|(producer_host, target_host)| producer_host == target_host), + _ => false, + } +} + +fn artifact_is_exact_candidate(manifest: &Value, artifact: &Value) -> bool { + artifact["artifactId"].as_str().is_some_and(safe_opaque_id) + && artifact["captureState"] == "captured" + && artifact_has_exact_public_provenance(artifact) + && artifact["collectedUtc"] + .as_str() + .is_some_and(|value| DateTime::parse_from_rfc3339(value).is_ok()) + && artifact["encoding"] == "utf-8" + && artifact["bytesCopied"].as_u64().is_some() + && artifact["collectionLimit"]["byteLimit"].as_u64().is_some() + && artifact["collectionLimit"]["limitApplied"] + .as_bool() + .is_some() + && artifact_path_matches_basename(artifact, "relativePath", "evidence/") + && rotation(&artifact["rotation"]).is_some() + && artifact_has_canonical_basename_rotation(artifact) + && artifact["rotation"]["lineageId"] + .as_str() + .is_some_and(safe_opaque_id) + && artifact["rotation"]["fragmentComplete"] == true + && artifact_has_exact_source_tuple(artifact) + && artifact_matches_topology(manifest, artifact) +} + +fn normalized_records( + scenario: &str, + manifest: &Value, +) -> BTreeMap<(String, u32, u32), SccmEvidence> { + try_normalized_records(scenario, manifest).expect("fixture records normalize") +} + +fn try_normalized_records( + scenario: &str, + manifest: &Value, +) -> Result, String> { + let mut records = BTreeMap::new(); + let artifacts = manifest["artifacts"] + .as_array() + .ok_or_else(|| format!("{scenario}: manifest artifacts are an array"))?; + for artifact in artifacts { + let state = required_string(artifact, "captureState", scenario)?; + if !matches!(state, "captured" | "capped") { + continue; + } + let artifact_id = required_string(artifact, "artifactId", scenario)?; + let relative_path = required_string(artifact, "relativePath", scenario)?; + if !safe_segmented_path(relative_path, "evidence/") { + return Err(format!( + "{scenario}/{artifact_id}: physical evidence path is safe" + )); + } + let content = std::fs::read_to_string(corpus_root().join(scenario).join(relative_path)) + .map_err(|error| { + format!("{scenario}/{artifact_id}: fixture evidence is readable UTF-8: {error}") + })?; + let model = SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: artifact["originalBasename"] + .as_str() + .ok_or_else(|| format!("{scenario}/{artifact_id}: artifact basename is a string"))? + .to_owned(), + original_path: None, + host: artifact["producerHostHandle"].as_str().map(str::to_owned), + role: SccmRole::SiteServer, + configmgr_version: artifact["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: artifact["collectedUtc"].as_str().map(str::to_owned), + rotation: rotation(&artifact["rotation"]) + .ok_or_else(|| format!("{scenario}/{artifact_id}: rotation is valid"))?, + coverage: coverage_state(state) + .ok_or_else(|| format!("{scenario}/{artifact_id}: coverage is valid"))?, + encoding: artifact["encoding"].as_str().map(str::to_owned), + }; + for record in normalize_ccm_artifact(model, &content) { + let line_start = record.reference.line_start.ok_or_else(|| { + format!("{scenario}/{artifact_id}: normalized evidence has a start line") + })?; + let line_end = record.reference.line_end.ok_or_else(|| { + format!("{scenario}/{artifact_id}: normalized evidence has an end line") + })?; + if records + .insert((artifact_id.to_owned(), line_start, line_end), record) + .is_some() + { + return Err(format!( + "{scenario}/{artifact_id}: duplicate physical logical evidence" + )); + } + } + } + Ok(records) +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct HierarchyCandidateKey { + message_id: String, + link_id: String, + origin_site_code: String, + target_site_code: String, + extraction_profile_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(transparent)] +struct HierarchyCandidateTimestamp(SccmTimestamp); + +impl Ord for HierarchyCandidateTimestamp { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0 + .original_display + .cmp(&other.0.original_display) + .then_with(|| self.0.offset_minutes.cmp(&other.0.offset_minutes)) + .then_with(|| self.0.utc_millis.cmp(&other.0.utc_millis)) + .then_with(|| { + timestamp_ordering_rank(&self.0.ordering_state) + .cmp(×tamp_ordering_rank(&other.0.ordering_state)) + }) + } +} + +impl PartialOrd for HierarchyCandidateTimestamp { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +const fn timestamp_ordering_rank(state: &SccmTimeOrderingState) -> u8 { + match state { + SccmTimeOrderingState::NormalizedUtc => 0, + SccmTimeOrderingState::OffsetMissing => 1, + SccmTimeOrderingState::OffsetInvalid => 2, + SccmTimeOrderingState::TimestampMissing => 3, + } +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct HierarchyCandidateFact { + phase: String, + disposition: String, + terminal: bool, + artifact_id: String, + producer_host_handle: String, + direction: String, + relative_path: String, + path_fingerprint: String, + rotation_kind: String, + rotation_value: Option, + rotation_lineage_id: String, + line_start: u32, + line_end: u32, + timestamp: HierarchyCandidateTimestamp, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +struct HierarchyCandidateGroup { + key: HierarchyCandidateKey, + facts: Vec, +} + +fn hierarchy_candidate_groups( + scenario: &str, + manifest: &Value, +) -> Result, String> { + let mut grouped_facts = + BTreeMap::>::new(); + let artifacts = manifest["artifacts"] + .as_array() + .ok_or_else(|| format!("{scenario}: artifacts are an array"))?; + for artifact in artifacts { + if !artifact_is_exact_candidate(manifest, artifact) { + continue; + } + let state = required_string(artifact, "captureState", scenario)?; + let artifact_id = required_string(artifact, "artifactId", scenario)?; + let basename = required_string(artifact, "originalBasename", scenario)?; + let relative_path = required_string(artifact, "relativePath", scenario)?; + let producer_host_handle = required_string(artifact, "producerHostHandle", scenario)?; + let direction = required_string(artifact, "direction", scenario)?; + let path_fingerprint = required_string(artifact, "pathFingerprint", scenario)?; + let rotation_kind = required_string(&artifact["rotation"], "kind", scenario)?; + let rotation_lineage_id = required_string(&artifact["rotation"], "lineageId", scenario)?; + let rotation_value = artifact["rotation"]["value"] + .as_str() + .map(str::to_owned) + .or_else(|| { + artifact["rotation"]["value"] + .as_u64() + .map(|value| value.to_string()) + }); + let content = std::fs::read_to_string(corpus_root().join(scenario).join(relative_path)) + .map_err(|error| { + format!("{scenario}/{artifact_id}: physical evidence is readable: {error}") + })?; + let model = SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: basename.to_owned(), + original_path: None, + host: Some(producer_host_handle.to_owned()), + role: SccmRole::SiteServer, + configmgr_version: artifact["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: artifact["collectedUtc"].as_str().map(str::to_owned), + rotation: rotation(&artifact["rotation"]) + .ok_or_else(|| format!("{scenario}/{artifact_id}: rotation is valid"))?, + coverage: coverage_state(state) + .ok_or_else(|| format!("{scenario}/{artifact_id}: coverage is valid"))?, + encoding: Some("utf-8".to_owned()), + }; + for record in normalize_ccm_artifact(model, &content) { + if record.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc { + continue; + } + let Ok(fields) = parse_fixture_fields(&record.message) else { + continue; + }; + let Some(message_id) = fields.get("MessageId") else { + continue; + }; + let Some(link_id) = fields.get("LinkId") else { + continue; + }; + let Some(origin_site_code) = fields.get("OriginSite") else { + continue; + }; + let Some(target_site_code) = fields.get("TargetSite") else { + continue; + }; + let Some(extraction_profile_id) = fields.get("ProfileId") else { + continue; + }; + let Some(phase) = fields.get("Phase") else { + continue; + }; + let Some(disposition) = fields.get("Disposition") else { + continue; + }; + let Some(terminal) = fields.get("Terminal") else { + continue; + }; + if extraction_profile_id != EXACT_PROFILE + || !phase_is_owned_by(basename, phase) + || !record_matches_topology(manifest, artifact, &fields) + { + continue; + } + let terminal = match terminal.as_str() { + "true" => true, + "false" => false, + _ => continue, + }; + let Some(line_start) = record.reference.line_start else { + continue; + }; + let Some(line_end) = record.reference.line_end else { + continue; + }; + let key = HierarchyCandidateKey { + message_id: message_id.to_owned(), + link_id: link_id.to_owned(), + origin_site_code: origin_site_code.to_owned(), + target_site_code: target_site_code.to_owned(), + extraction_profile_id: extraction_profile_id.to_owned(), + }; + let fact = HierarchyCandidateFact { + phase: phase.to_owned(), + disposition: disposition.to_owned(), + terminal, + artifact_id: artifact_id.to_owned(), + producer_host_handle: candidate_provenance_token( + CandidateProvenanceDomain::ProducerHost, + producer_host_handle, + ), + direction: direction.to_owned(), + relative_path: relative_path.to_owned(), + path_fingerprint: candidate_provenance_token( + CandidateProvenanceDomain::PathFingerprint, + path_fingerprint, + ), + rotation_kind: rotation_kind.to_owned(), + rotation_value: rotation_value.clone(), + rotation_lineage_id: rotation_lineage_id.to_owned(), + line_start, + line_end, + timestamp: HierarchyCandidateTimestamp(record.timestamp.clone()), + }; + grouped_facts.entry(key).or_default().insert(fact); + } + } + Ok(grouped_facts + .into_iter() + .map(|(key, facts)| HierarchyCandidateGroup { + key, + facts: facts.into_iter().collect(), + }) + .collect()) +} + +fn hierarchy_candidate_bytes(scenario: &str, manifest: &Value) -> Result, String> { + serde_json::to_vec(&hierarchy_candidate_groups(scenario, manifest)?) + .map_err(|error| format!("{scenario}: candidate output serializes: {error}")) +} + +fn phase_is_owned_by(basename: &str, phase: &str) -> bool { + matches!( + (basename, phase), + ("replmgr.log", "initiate" | "queueOrSerialize") + | ("sender.log" | "sender.lo_", "send") + | ("despool.log", "receive" | "process" | "healthyOrTerminal") + | ("rcmctrl.log", "acknowledge" | "healthyOrTerminal") + ) +} + +fn evidence_reference_key(reference: &Value) -> Option<(String, u32, u32)> { + let artifact_id = reference["artifactId"] + .as_str() + .filter(|value| !value.is_empty())? + .to_owned(); + let line_start = reference["startLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok())?; + let line_end = reference["endLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok())?; + (line_start <= line_end).then_some((artifact_id, line_start, line_end)) +} + +#[derive(Debug)] +struct UsableTimestampCursor { + artifact_id: String, + line_end: u32, + utc_millis: i64, +} + +fn advance_usable_timestamp_sequence( + prior: &mut Option, + artifact_id: &str, + record: &SccmEvidence, +) -> Result<(), &'static str> { + let (Some(line_start), Some(line_end), Some(utc_millis)) = ( + record.reference.line_start, + record.reference.line_end, + record.timestamp.utc_millis, + ) else { + return Err("unusable or reversed time"); + }; + if record.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + || line_start > line_end + { + return Err("unusable or reversed time"); + } + if let Some(previous) = prior { + if utc_millis < previous.utc_millis { + return Err("unusable or reversed time"); + } + if utc_millis == previous.utc_millis && artifact_id != previous.artifact_id { + return Err("equal UTC across distinct artifacts is unusable"); + } + if utc_millis == previous.utc_millis && line_start <= previous.line_end { + return Err("equal UTC without forward physical lines is unusable"); + } + } + *prior = Some(UsableTimestampCursor { + artifact_id: artifact_id.to_owned(), + line_end, + utc_millis, + }); + Ok(()) +} + +fn observation_matches_record( + manifest: &Value, + artifact: &Value, + transaction: &Value, + observation: &Value, + record: &SccmEvidence, +) -> bool { + let Ok(fields) = parse_fixture_fields(&record.message) else { + return false; + }; + let key = &transaction["key"]; + fields.get("MessageId").map(String::as_str) == key["messageId"].as_str() + && fields.get("LinkId").map(String::as_str) == key["linkId"].as_str() + && fields.get("OriginSite").map(String::as_str) == key["originSiteCode"].as_str() + && fields.get("TargetSite").map(String::as_str) == key["targetSiteCode"].as_str() + && fields.get("ProfileId").map(String::as_str) == Some(EXACT_PROFILE) + && fields.get("Phase").map(String::as_str) == observation["phase"].as_str() + && fields.get("Disposition").map(String::as_str) == observation["disposition"].as_str() + && fields.get("Terminal").map(String::as_str) + == observation["terminal"] + .as_bool() + .map(|terminal| if terminal { "true" } else { "false" }) + && artifact["originalBasename"] + .as_str() + .zip(observation["phase"].as_str()) + .is_some_and(|(basename, phase)| phase_is_owned_by(basename, phase)) + && artifact_has_exact_source_tuple(artifact) + && record_matches_topology(manifest, artifact, &fields) +} + +fn transaction_semantics_are_coherent(transaction: &Value, manifest: &Value) -> bool { + let Some(observations) = transaction["observations"].as_array() else { + return false; + }; + let mut retrying = false; + let mut terminal_success = false; + let mut terminal_failure = false; + for observation in observations { + let Some(disposition) = observation["disposition"].as_str() else { + return false; + }; + let Some(terminal) = observation["terminal"].as_bool() else { + return false; + }; + if !observation_disposition_is_coherent(disposition, terminal) { + return false; + } + retrying |= disposition == "retrying"; + terminal_success |= terminal && disposition == "succeeded"; + terminal_failure |= terminal && disposition == "failed"; + } + let terminal_evidence = terminal_success || terminal_failure; + if transaction["terminalEvidence"].as_bool() != Some(terminal_evidence) { + return false; + } + + let target_source_missing = transaction["key"]["targetSiteCode"] + .as_str() + .is_some_and(|target_site| required_target_source_missing(manifest, target_site)); + let (state, classification) = + if transaction["timestampOrdering"] != "usable" || target_source_missing { + ("incomplete", "insufficientEvidence") + } else if terminal_failure { + ("failed", "confirmedFailure") + } else if terminal_success && retrying { + ("recovered", "success") + } else if terminal_success { + ("succeeded", "success") + } else if retrying { + ("deferred", "blockedOrDeferred") + } else { + ("incomplete", "insufficientEvidence") + }; + transaction["state"] == state && transaction["classification"] == classification +} + +fn observation_disposition_is_coherent(disposition: &str, terminal: bool) -> bool { + matches!( + (disposition, terminal), + ("succeeded", false | true) | ("failed", true) | ("retrying", false) + ) +} + +fn expected_transaction_ids(scenario: &str) -> Option<&'static [&'static str]> { + match scenario { + "absent-remote-source" => Some(&["hierarchy:msg-absent-01:LAB:CHD:link-lab-chd"]), + "backlog-retry" => Some(&["hierarchy:msg-backlog-01:LAB:CHD:link-lab-chd"]), + "clock-offset-unknown" => Some(&["hierarchy:msg-clock-01:LAB:CHD:link-lab-chd"]), + "healthy-link" => Some(&["hierarchy:msg-healthy-01:LAB:CHD:link-lab-chd"]), + "receiver-processing-failure" => Some(&["hierarchy:msg-receiver-01:LAB:CHD:link-lab-chd"]), + "recovery" => Some(&["hierarchy:msg-recovery-01:LAB:CHD:link-lab-chd"]), + "sender-failure" => Some(&[ + "hierarchy:msg-send-chd:LAB:CHD:link-lab-chd", + "hierarchy:msg-send-sec:LAB:SEC:link-lab-sec", + ]), + "topology-mismatch" => Some(&[ + "hierarchy:msg-mismatch-01:LAB:CHD:link-lab-chd", + "hierarchy:msg-mismatch-01:LAB:SEC:link-lab-sec", + ]), + "generic-site-token" | "incomplete" | "rotation-boundary" => Some(&[]), + _ => None, + } +} + +fn expected_observation_ids(scenario: &str) -> Option<&'static [&'static str]> { + match scenario { + "absent-remote-source" => Some(&["absent-01-send"]), + "backlog-retry" => Some(&["backlog-01-queue"]), + "clock-offset-unknown" => Some(&["clock-01-send", "clock-02-process"]), + "healthy-link" => Some(&[ + "healthy-01-initiate", + "healthy-02-queue", + "healthy-03-send", + "healthy-04-receive", + "healthy-05-process", + "healthy-06-acknowledge", + "healthy-07-terminal", + ]), + "receiver-processing-failure" => Some(&[ + "receiver-01-send", + "receiver-02-receive", + "receiver-03-process", + ]), + "recovery" => Some(&[ + "recovery-01-retry", + "recovery-02-send", + "recovery-03-receive", + "recovery-04-process", + "recovery-05-terminal", + ]), + "sender-failure" => Some(&["sender-01-chd-failure", "sender-02-sec-failure"]), + "topology-mismatch" => Some(&["mismatch-01-origin", "mismatch-02-target"]), + "generic-site-token" | "incomplete" | "rotation-boundary" => Some(&[]), + _ => None, + } +} + +fn expected_source_local_ids(scenario: &str) -> Option<&'static [&'static str]> { + match scenario { + "incomplete" => Some(&["incomplete-01-fragment"]), + "rotation-boundary" => Some(&["rotation-01-split"]), + "absent-remote-source" + | "backlog-retry" + | "clock-offset-unknown" + | "generic-site-token" + | "healthy-link" + | "receiver-processing-failure" + | "recovery" + | "sender-failure" + | "topology-mismatch" => Some(&[]), + _ => None, + } +} + +fn object_has_only(value: &Value, fields: &[&str]) -> bool { + value + .as_object() + .is_some_and(|object| object.keys().all(|field| fields.contains(&field.as_str()))) +} + +fn declared_target_site_codes(manifest: &Value) -> BTreeSet<&str> { + std::iter::once(manifest["topology"]["targetSiteCode"].as_str()) + .chain( + manifest["topology"]["additionalTargets"] + .as_array() + .into_iter() + .flatten() + .map(|target| target["siteCode"].as_str()), + ) + .flatten() + .collect() +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct ArtifactRequestBasis { + source_id: String, + direction: String, + target_site_code: String, + basenames: Vec, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct ArtifactRequestContract { + basis: ArtifactRequestBasis, + reason_code: String, +} + +fn exact_request_direction(directions: &BTreeSet<&str>) -> Option { + match directions.iter().copied().collect::>().as_slice() { + ["origin"] => Some("origin".to_owned()), + ["target"] => Some("target".to_owned()), + ["origin", "target"] => Some("both".to_owned()), + _ => None, + } +} + +fn exact_target_site_for_artifact(manifest: &Value, artifact: &Value) -> Option { + let producer_host = artifact["producerHostHandle"].as_str()?; + match artifact["direction"].as_str()? { + "origin" => { + if manifest["topology"]["originHostHandle"].as_str() != Some(producer_host) { + return None; + } + let target_sites = declared_target_site_codes(manifest); + (target_sites.len() == 1) + .then(|| target_sites.into_iter().next().map(str::to_owned)) + .flatten() + } + "target" => { + let matching_sites = std::iter::once(( + manifest["topology"]["targetSiteCode"].as_str(), + manifest["topology"]["targetHostHandle"].as_str(), + )) + .chain( + manifest["topology"]["additionalTargets"] + .as_array() + .into_iter() + .flatten() + .map(|target| (target["siteCode"].as_str(), target["hostHandle"].as_str())), + ) + .filter_map(|(site, host)| (host == Some(producer_host)).then_some(site).flatten()) + .collect::>(); + (matching_sites.len() == 1) + .then(|| matching_sites.into_iter().next().map(str::to_owned)) + .flatten() + } + _ => None, + } +} + +fn coverage_request_basis( + manifest: &Value, + source_id: &str, + reason: &str, +) -> Option { + let matching = manifest["artifacts"] + .as_array()? + .iter() + .filter(|artifact| { + artifact["sourceId"].as_str() == Some(source_id) + && match reason { + "coverageAbsent" => artifact["captureState"] == "absent", + "coverageCapped" => artifact["captureState"] == "capped", + "coverageRotationSplit" => { + matches!( + artifact["captureState"].as_str(), + Some("captured" | "capped") + ) && artifact["rotation"]["fragmentComplete"] == false + } + _ => false, + } + }) + .collect::>(); + if matching.is_empty() { + return None; + } + + let directions = matching + .iter() + .map(|artifact| artifact["direction"].as_str()) + .collect::>>()?; + let target_sites = matching + .iter() + .map(|artifact| exact_target_site_for_artifact(manifest, artifact)) + .collect::>>()?; + let basenames = matching + .iter() + .map(|artifact| artifact["originalBasename"].as_str().map(str::to_owned)) + .collect::>>()?; + + if target_sites.len() != 1 { + return None; + } + if reason == "coverageRotationSplit" { + let lineages = matching + .iter() + .map(|artifact| artifact["rotation"]["lineageId"].as_str()) + .collect::>>()?; + let canonical_rotation = matching + .iter() + .all(|artifact| artifact_has_canonical_basename_rotation(artifact)); + let canonical_basenames = + BTreeSet::from(["sender.lo_".to_owned(), "sender.log".to_owned()]); + if matching.len() != 2 + || lineages.len() != 1 + || !canonical_rotation + || basenames != canonical_basenames + { + return None; + } + } + + Some(ArtifactRequestBasis { + source_id: source_id.to_owned(), + direction: exact_request_direction(&directions)?, + target_site_code: target_sites.into_iter().next()?, + basenames: basenames.into_iter().collect(), + }) +} + +fn invalid_offset_request_basis(scenario: &str, manifest: &Value) -> Option { + let mut source_ids = BTreeSet::new(); + let mut directions = BTreeSet::new(); + let mut target_sites = BTreeSet::new(); + let mut basenames = BTreeSet::new(); + + for artifact in manifest["artifacts"].as_array()? { + let state = artifact["captureState"].as_str()?; + if !matches!(state, "captured" | "capped") { + continue; + } + let artifact_id = artifact["artifactId"].as_str()?; + let source_id = artifact["sourceId"].as_str()?; + let direction = artifact["direction"].as_str()?; + let basename = artifact["originalBasename"].as_str()?; + let relative_path = artifact["relativePath"].as_str()?; + if !safe_segmented_path(relative_path, "evidence/") { + return None; + } + let content = + std::fs::read_to_string(corpus_root().join(scenario).join(relative_path)).ok()?; + let model = SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: basename.to_owned(), + original_path: None, + host: artifact["producerHostHandle"].as_str().map(str::to_owned), + role: SccmRole::SiteServer, + configmgr_version: artifact["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: artifact["collectedUtc"].as_str().map(str::to_owned), + rotation: rotation(&artifact["rotation"])?, + coverage: coverage_state(state)?, + encoding: artifact["encoding"].as_str().map(str::to_owned), + }; + for record in normalize_ccm_artifact(model, &content) { + if record.timestamp.ordering_state != SccmTimeOrderingState::OffsetInvalid { + continue; + } + let Ok(fields) = parse_fixture_fields(&record.message) else { + continue; + }; + let target_site = fields.get("TargetSite")?; + source_ids.insert(source_id.to_owned()); + directions.insert(direction.to_owned()); + target_sites.insert(target_site.to_owned()); + basenames.insert(basename.to_owned()); + } + } + + let direction_values = directions.iter().map(String::as_str).collect::>(); + let direction = match direction_values.as_slice() { + ["origin"] => "origin", + ["target"] => "target", + ["origin", "target"] => "both", + _ => return None, + }; + if source_ids.len() != 1 || target_sites.len() != 1 { + return None; + } + Some(ArtifactRequestBasis { + source_id: source_ids.into_iter().next()?, + direction: direction.to_owned(), + target_site_code: target_sites.into_iter().next()?, + basenames: basenames.into_iter().collect(), + }) +} + +fn target_source_usable( + manifest: &Value, + target_site: &str, + source_id: &str, + basename: &str, +) -> bool { + let Some(target_host) = target_host_for_site(manifest, target_site) else { + return false; + }; + manifest["artifacts"] + .as_array() + .into_iter() + .flatten() + .any(|artifact| { + artifact["sourceId"].as_str() == Some(source_id) + && artifact["originalBasename"].as_str() == Some(basename) + && artifact["direction"] == "target" + && artifact["producerHostHandle"].as_str() == Some(target_host) + && artifact["captureState"] == "captured" + }) +} + +fn required_target_source_missing(manifest: &Value, target_site: &str) -> bool { + [ + ("server-hierarchy-control", "rcmctrl.log"), + ("server-hierarchy-transfer", "despool.log"), + ] + .into_iter() + .any(|(source_id, basename)| !target_source_usable(manifest, target_site, source_id, basename)) +} + +fn missing_target_source_requests( + manifest: &Value, + expected: &Value, +) -> BTreeSet { + let mut requests = BTreeSet::new(); + for target_site in expected["transactions"] + .as_array() + .into_iter() + .flatten() + .filter_map(|transaction| transaction["key"]["targetSiteCode"].as_str()) + { + for (source_id, basename) in [ + ("server-hierarchy-control", "rcmctrl.log"), + ("server-hierarchy-transfer", "despool.log"), + ] { + if target_source_usable(manifest, target_site, source_id, basename) { + continue; + } + requests.insert(ArtifactRequestContract { + basis: ArtifactRequestBasis { + source_id: source_id.to_owned(), + direction: "target".to_owned(), + target_site_code: target_site.to_owned(), + basenames: vec![basename.to_owned()], + }, + reason_code: "missingTargetReceiveProcessApply".to_owned(), + }); + } + } + requests +} + +fn derived_artifact_requests( + scenario: &str, + manifest: &Value, + expected: &Value, +) -> BTreeSet { + let mut requests = BTreeSet::new(); + for source_id in ["server-hierarchy-control", "server-hierarchy-transfer"] { + for reason_code in ["coverageAbsent", "coverageCapped", "coverageRotationSplit"] { + if let Some(basis) = coverage_request_basis(manifest, source_id, reason_code) { + requests.insert(ArtifactRequestContract { + basis, + reason_code: reason_code.to_owned(), + }); + } + } + } + let missing_target_requests = missing_target_source_requests(manifest, expected); + requests.retain(|request| { + !missing_target_requests + .iter() + .any(|missing| request.basis == missing.basis) + }); + if missing_target_requests.is_empty() { + if let Some(basis) = invalid_offset_request_basis(scenario, manifest) { + requests.insert(ArtifactRequestContract { + basis, + reason_code: "invalidOffset".to_owned(), + }); + } + } + requests.extend(missing_target_requests); + requests +} + +fn declared_artifact_request(request: &Value) -> Option { + Some(ArtifactRequestContract { + basis: ArtifactRequestBasis { + source_id: request["sourceId"].as_str()?.to_owned(), + direction: request["direction"].as_str()?.to_owned(), + target_site_code: request["targetSiteCode"].as_str()?.to_owned(), + basenames: request["basenames"] + .as_array()? + .iter() + .map(|value| value.as_str().map(str::to_owned)) + .collect::>>()?, + }, + reason_code: request["reasonCode"].as_str()?.to_owned(), + }) +} + +fn artifact_request_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec { + let mut failures = Vec::new(); + let Some(requests) = expected["artifactRequests"].as_array() else { + failures.push(format!("{scenario}: artifact requests are not an array")); + return failures; + }; + let target_sites = declared_target_site_codes(manifest); + let request_keys = requests + .iter() + .map(|request| { + ( + request["targetSiteCode"].as_str(), + request["sourceId"].as_str(), + request["direction"].as_str(), + request["reasonCode"].as_str(), + ) + }) + .collect::>(); + let mut sorted_request_keys = request_keys.clone(); + sorted_request_keys.sort_unstable(); + sorted_request_keys.dedup(); + if request_keys != sorted_request_keys { + failures.push(format!("{scenario}: requests are not sorted and unique")); + } + + for request in requests { + if !object_has_only( + request, + &[ + "sourceId", + "producerRole", + "direction", + "targetSiteCode", + "basenames", + "reasonCode", + ], + ) { + failures.push(format!( + "{scenario}: request has an unsupported field or shape" + )); + } + let source_id = request["sourceId"].as_str(); + let direction = request["direction"].as_str(); + let target_site = request["targetSiteCode"].as_str(); + let reason = request["reasonCode"].as_str(); + let basename_values = request["basenames"].as_array(); + let basenames = basename_values + .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .unwrap_or_default(); + let mut sorted_basenames = basenames.clone(); + sorted_basenames.sort_unstable(); + sorted_basenames.dedup(); + let source_owns_basenames = source_id.is_some_and(|source_id| { + basenames.iter().all(|basename| { + ["origin", "target"].iter().any(|direction| { + exact_source_tuple(source_id, basename, direction, "siteServer") + }) + }) + }); + if request["producerRole"] != "siteServer" + || !matches!(direction, Some("origin" | "target" | "both")) + || target_site.is_none_or(|site| !target_sites.contains(site)) + || basename_values.is_none_or(|values| values.len() != basenames.len()) + || basenames.is_empty() + || basenames != sorted_basenames + || !source_owns_basenames + { + failures.push(format!("{scenario}: request is broad or malformed")); + continue; + } + + let actual_basis = ArtifactRequestBasis { + source_id: source_id.unwrap_or_default().to_owned(), + direction: direction.unwrap_or_default().to_owned(), + target_site_code: target_site.unwrap_or_default().to_owned(), + basenames: basenames.iter().map(|value| (*value).to_owned()).collect(), + }; + let backed = match reason { + Some("invalidOffset") => { + invalid_offset_request_basis(scenario, manifest).as_ref() == Some(&actual_basis) + } + Some(reason @ ("coverageAbsent" | "coverageCapped" | "coverageRotationSplit")) => { + coverage_request_basis(manifest, source_id.unwrap_or_default(), reason).as_ref() + == Some(&actual_basis) + } + Some("missingTargetReceiveProcessApply") => missing_target_source_requests( + manifest, expected, + ) + .contains(&ArtifactRequestContract { + basis: actual_basis, + reason_code: "missingTargetReceiveProcessApply".to_owned(), + }), + _ => false, + }; + if !backed { + failures.push(format!( + "{scenario}: request is not backed by exact coverage/time evidence" + )); + } + } + + let declared_requests = requests + .iter() + .filter_map(declared_artifact_request) + .collect::>(); + if declared_requests.len() != requests.len() + || declared_requests != derived_artifact_requests(scenario, manifest, expected) + { + failures.push(format!( + "{scenario}: artifact requests are not the complete derived bounded set" + )); + } + + failures +} + +fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec { + let mut failures = Vec::new(); + if !object_has_only( + manifest, + &[ + "sccmManifestVersion", + "proposalOnly", + "syntheticFixture", + "scenario", + "bundle", + "topology", + "artifacts", + ], + ) || !object_has_only( + &manifest["bundle"], + &["bundleRole", "workflow", "capturedUtc"], + ) || !object_has_only( + &manifest["topology"], + &[ + "originSiteCode", + "targetSiteCode", + "originHostHandle", + "targetHostHandle", + "additionalTargets", + "rolesObserved", + ], + ) { + failures.push("manifest contains an unsupported field or shape".to_owned()); + } + if manifest["sccmManifestVersion"] != 1 + || manifest["proposalOnly"] != true + || manifest["syntheticFixture"] != true + || manifest["scenario"] != scenario + || manifest["bundle"]["bundleRole"] != "server" + || manifest["bundle"]["workflow"] != "hierarchyAndReplication" + || manifest["bundle"]["capturedUtc"] + .as_str() + .is_none_or(|value| DateTime::parse_from_rfc3339(value).is_err()) + { + failures.push("manifest loses the versioned synthetic preparation boundary".to_owned()); + } + let topology = &manifest["topology"]; + let origin_host = topology["originHostHandle"].as_str(); + let primary_target_site = topology["targetSiteCode"].as_str(); + let primary_target_host = topology["targetHostHandle"].as_str(); + if topology["originSiteCode"] + .as_str() + .is_none_or(str::is_empty) + || primary_target_site.is_none_or(str::is_empty) + || origin_host.is_none_or(|value| !safe_server_handle(value)) + || primary_target_host.is_none_or(|value| !safe_server_handle(value)) + || origin_host == primary_target_host + { + failures.push("topology lacks exact safe origin/target identity".to_owned()); + } + let mut topology_target_sites = BTreeSet::new(); + let mut topology_target_hosts = BTreeSet::new(); + let mut topology_target_host_by_site = BTreeMap::new(); + if let (Some(site), Some(host)) = (primary_target_site, primary_target_host) { + topology_target_sites.insert(site); + topology_target_hosts.insert(host); + topology_target_host_by_site.insert(site, host); + } + if let Some(additional_targets) = topology.get("additionalTargets") { + let Some(additional_targets) = additional_targets.as_array() else { + failures.push("additional topology targets are not an array".to_owned()); + return failures; + }; + for target in additional_targets { + if !object_has_only(target, &["siteCode", "hostHandle"]) { + failures.push("additional topology target has unsupported fields".to_owned()); + continue; + } + let site = target["siteCode"].as_str(); + let host = target["hostHandle"].as_str(); + if site.is_none_or(str::is_empty) + || host.is_none_or(|value| !safe_server_handle(value)) + || host == origin_host + || !topology_target_sites.insert(site.unwrap_or_default()) + || !topology_target_hosts.insert(host.unwrap_or_default()) + { + failures.push("additional topology target is invalid or duplicated".to_owned()); + } else if let (Some(site), Some(host)) = (site, host) { + topology_target_host_by_site.insert(site, host); + } + } + } + let role_values = manifest["topology"]["rolesObserved"].as_array(); + if role_values.is_none_or(|roles| { + roles.len() != 1 || roles.iter().any(|role| role.as_str() != Some("siteServer")) + }) { + failures.push("topology roles are not exact strings".to_owned()); + } + + let artifacts = manifest["artifacts"].as_array(); + if artifacts.is_none() { + failures.push("artifacts is not an array".to_owned()); + } + let mut artifact_ids = Vec::new(); + let mut destinations = BTreeSet::new(); + let mut fingerprints = BTreeSet::new(); + for artifact in artifacts.into_iter().flatten() { + if !object_has_only( + artifact, + &[ + "artifactId", + "sourceId", + "producerRole", + "producerHostHandle", + "direction", + "originalBasename", + "sanitizedSourcePath", + "pathFingerprint", + "rotation", + "captureState", + "sourceVersion", + "collectedUtc", + "encoding", + "collectionLimit", + "bytesCopied", + "relativePath", + ], + ) || !object_has_only( + &artifact["rotation"], + &["kind", "value", "lineageId", "fragmentComplete"], + ) || artifact.get("collectionLimit").is_some() + && !object_has_only(&artifact["collectionLimit"], &["byteLimit", "limitApplied"]) + { + failures.push("artifact contains an unsupported field or shape".to_owned()); + } + let Some(artifact_id) = artifact["artifactId"] + .as_str() + .filter(|value| safe_opaque_id(value)) + else { + failures.push("artifactId is not a bounded safe opaque string".to_owned()); + continue; + }; + artifact_ids.push(artifact_id); + let state = artifact["captureState"].as_str(); + if state.and_then(coverage_state).is_none() { + failures.push(format!("{artifact_id}: invalid coverage type/state")); + } + if artifact["producerRole"] != "siteServer" + || !matches!(artifact["direction"].as_str(), Some("origin" | "target")) + || !artifact_has_exact_source_tuple(artifact) + || !artifact_has_exact_public_provenance(artifact) + || !artifact_has_canonical_basename_rotation(artifact) + || artifact["collectedUtc"] + .as_str() + .is_none_or(|value| DateTime::parse_from_rfc3339(value).is_err()) + { + failures.push(format!("{artifact_id}: invalid typed provenance")); + } + let direction = artifact["direction"].as_str(); + let producer_host = artifact["producerHostHandle"].as_str(); + match direction { + Some("origin") if producer_host != topology["originHostHandle"].as_str() => { + failures.push(format!( + "{artifact_id}: origin evidence host diverges from topology" + )); + } + Some("target") + if producer_host.is_none_or(|host| !topology_target_hosts.contains(host)) => + { + failures.push(format!( + "{artifact_id}: target evidence host is outside topology" + )); + } + _ => {} + } + if artifact["pathFingerprint"] + .as_str() + .map(str::to_ascii_lowercase) + .is_none_or(|value| !fingerprints.insert(value)) + { + failures.push(format!("{artifact_id}: duplicate or invalid fingerprint")); + } + if rotation(&artifact["rotation"]).is_none() + || artifact["rotation"]["lineageId"] + .as_str() + .is_none_or(|value| !safe_opaque_id(value)) + { + failures.push(format!("{artifact_id}: invalid rotation provenance")); + } + match state { + Some("captured" | "capped" | "parseFailed") => { + let relative_path = artifact["relativePath"].as_str(); + if !artifact_path_matches_basename(artifact, "relativePath", "evidence/") + || relative_path + .map(str::to_ascii_lowercase) + .is_none_or(|value| !destinations.insert(value)) + || artifact["bytesCopied"].as_u64().is_none() + || artifact["encoding"] != "utf-8" + || artifact["collectionLimit"]["byteLimit"].as_u64().is_none() + || artifact["collectionLimit"]["limitApplied"] + .as_bool() + .is_none() + || artifact["rotation"]["fragmentComplete"].as_bool().is_none() + { + failures.push(format!("{artifact_id}: invalid physical provenance")); + } + if direction == Some("target") + && relative_path.is_some_and(|path| safe_segmented_path(path, "evidence/")) + && producer_host.is_some() + { + let path = corpus_root() + .join(scenario) + .join(relative_path.unwrap_or_default()); + let Ok(content) = std::fs::read_to_string(path) else { + failures.push(format!( + "{artifact_id}: target evidence is unavailable for topology validation" + )); + continue; + }; + let model = SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: artifact["originalBasename"] + .as_str() + .unwrap_or_default() + .to_owned(), + original_path: None, + host: producer_host.map(str::to_owned), + role: SccmRole::SiteServer, + configmgr_version: artifact["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: artifact["collectedUtc"].as_str().map(str::to_owned), + rotation: rotation(&artifact["rotation"]).unwrap_or(SccmRotation::Current), + coverage: state + .and_then(coverage_state) + .unwrap_or(SccmCoverageState::ParseFailed), + encoding: Some("utf-8".to_owned()), + }; + for record in normalize_ccm_artifact(model, &content) { + let Ok(fields) = parse_fixture_fields(&record.message) else { + continue; + }; + let target_site = fields.get("TargetSite").map(String::as_str); + if target_site + .and_then(|site| topology_target_host_by_site.get(site).copied()) + != producer_host + { + failures.push(format!( + "{artifact_id}: target evidence host does not match record topology" + )); + } + } + } + } + Some("absent" | "accessDenied" | "skipped" | "unsupported") + if artifact.get("relativePath").is_some() + || artifact.get("bytesCopied").is_some() + || artifact.get("encoding").is_some() + || artifact.get("collectionLimit").is_some() + || artifact["rotation"].get("fragmentComplete").is_some() => + { + failures.push(format!( + "{artifact_id}: nonphysical state invents physical provenance" + )); + } + _ => {} + } + } + let mut sorted_artifact_ids = artifact_ids.clone(); + sorted_artifact_ids.sort_unstable(); + sorted_artifact_ids.dedup(); + if artifact_ids != sorted_artifact_ids { + failures.push("artifact IDs are not sorted and unique".to_owned()); + } + + let manifest_coverage = artifacts + .into_iter() + .flatten() + .map(|artifact| { + artifact["artifactId"] + .as_str() + .zip(artifact["captureState"].as_str()) + }) + .collect::>>(); + let coverage_values = expected["coverage"].as_array(); + let declared_coverage = coverage_values.and_then(|rows| { + rows.iter() + .map(|row| { + if !object_has_only(row, &["artifactId", "state"]) { + return None; + } + row["artifactId"] + .as_str() + .filter(|artifact_id| !artifact_id.is_empty()) + .zip(row["state"].as_str()) + }) + .collect::>>() + }); + if manifest_coverage.as_ref() != declared_coverage.as_ref() + || declared_coverage.as_ref().is_some_and(|rows| { + rows.iter() + .any(|(_, state)| coverage_state(state).is_none()) + }) + { + failures.push("coverage rows are not the exact typed manifest projection".to_owned()); + } + + if !object_has_only( + expected, + &[ + "contractState", + "workflow", + "scenario", + "stateChain", + "analysisContract", + "extractionProfile", + "coverage", + "transactions", + "sourceLocalObservations", + "artifactRequests", + "crossSideCausalClaims", + "correlationHandoff", + ], + ) || expected["contractState"] != "proposedPendingReviewed318And335" + || expected["workflow"] != "hierarchyAndReplication" + || expected["scenario"] != scenario + { + failures.push("expected output loses the preparation boundary".to_owned()); + } + let state_values = expected["stateChain"].as_array(); + let state_chain = state_values + .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .unwrap_or_default(); + if state_values.is_none_or(|values| values.len() != state_chain.len()) + || state_chain != STATE_CHAIN + { + failures.push("state chain is not exact typed #331 grammar".to_owned()); + } + if !object_has_only( + &expected["analysisContract"], + &[ + "independentReducer", + "crossSideCorrelationPerformed", + "nativeCollectionPerformed", + ], + ) || !object_has_only( + &expected["extractionProfile"], + &["selectionState", "profileId", "validatedRole"], + ) || !object_has_only( + &expected["correlationHandoff"], + &["issue", "performed", "timeOnlyEligible"], + ) || expected["analysisContract"]["independentReducer"] != true + || expected["analysisContract"]["crossSideCorrelationPerformed"] != false + || expected["analysisContract"]["nativeCollectionPerformed"] != false + || expected["extractionProfile"]["selectionState"] != "selectedSynthetic" + || expected["extractionProfile"]["profileId"] != EXACT_PROFILE + || expected["extractionProfile"]["validatedRole"] != "siteServer" + || expected["crossSideCausalClaims"] != Value::Array(Vec::new()) + || expected["correlationHandoff"]["issue"] != "#333" + || expected["correlationHandoff"]["performed"] != false + || expected["correlationHandoff"]["timeOnlyEligible"] != false + { + failures + .push("expected output enables unsupported production/correlation state".to_owned()); + } + + let normalized = match try_normalized_records(scenario, manifest) { + Ok(records) => records, + Err(error) => { + failures.push(error); + BTreeMap::new() + } + }; + let artifact_by_id = artifacts + .into_iter() + .flatten() + .filter_map(|artifact| { + artifact["artifactId"] + .as_str() + .map(|artifact_id| (artifact_id, artifact)) + }) + .collect::>(); + + let transactions = expected["transactions"].as_array(); + let transaction_ids = transactions + .into_iter() + .flatten() + .filter_map(|transaction| transaction["transactionId"].as_str()) + .collect::>(); + match expected_transaction_ids(scenario) { + Some(expected_ids) if transaction_ids.as_slice() == expected_ids => {} + Some(_) => failures.push("transaction identity/cardinality matrix changed".to_owned()), + None => failures.push(format!( + "{scenario}: scenario is not registered in the transaction identity matrix" + )), + } + for transaction in transactions.into_iter().flatten() { + let mut prior_timestamp = None; + if !object_has_only( + transaction, + &[ + "transactionId", + "key", + "topologyCompatibility", + "timestampOrdering", + "terminalEvidence", + "state", + "classification", + "confidence", + "confidenceCeiling", + "coverageGapArtifactIds", + "observations", + ], + ) || !object_has_only( + &transaction["key"], + &[ + "messageId", + "linkId", + "originSiteCode", + "targetSiteCode", + "confidence", + "extractionProfileId", + ], + ) { + failures.push("transaction contains an unsupported field or shape".to_owned()); + } + let transaction_id = transaction["transactionId"].as_str().unwrap_or_default(); + let key = &transaction["key"]; + let derived_id = [ + key["messageId"].as_str(), + key["originSiteCode"].as_str(), + key["targetSiteCode"].as_str(), + key["linkId"].as_str(), + ]; + if derived_id + .iter() + .any(|value| value.is_none_or(str::is_empty)) + || key["confidence"] != "exact" + || key["extractionProfileId"] != EXACT_PROFILE + || transaction_id + != format!( + "hierarchy:{}:{}:{}:{}", + derived_id[0].unwrap_or_default(), + derived_id[1].unwrap_or_default(), + derived_id[2].unwrap_or_default(), + derived_id[3].unwrap_or_default() + ) + { + failures.push("transaction is not derived from one exact immutable key".to_owned()); + } + if key["originSiteCode"].as_str() != topology["originSiteCode"].as_str() + || key["targetSiteCode"] + .as_str() + .is_none_or(|site| !topology_target_sites.contains(site)) + { + failures.push("transaction key is outside declared topology".to_owned()); + } + if !transaction_semantics_are_coherent(transaction, manifest) { + failures + .push("transaction state/classification is not derived from its facts".to_owned()); + } + let gap_values = transaction["coverageGapArtifactIds"].as_array(); + let gap_ids = gap_values + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .filter(|artifact_id| !artifact_id.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + let mut sorted_gap_ids = gap_ids.clone(); + sorted_gap_ids.sort_unstable(); + sorted_gap_ids.dedup(); + if gap_values.is_none_or(|values| values.len() != gap_ids.len()) + || gap_ids != sorted_gap_ids + { + failures.push("coverage gap IDs are not exact sorted strings".to_owned()); + } + for gap_id in &gap_ids { + let manifest_matches = artifacts + .into_iter() + .flatten() + .filter(|artifact| artifact["artifactId"].as_str() == Some(*gap_id)) + .collect::>(); + let coverage_matches = coverage_values + .into_iter() + .flatten() + .filter(|row| row["artifactId"].as_str() == Some(*gap_id)) + .collect::>(); + if manifest_matches.len() != 1 + || coverage_matches.len() != 1 + || manifest_matches[0]["captureState"] + .as_str() + .and_then(coverage_state) + .is_none() + || manifest_matches[0]["captureState"] == "captured" + || coverage_matches[0]["state"] != manifest_matches[0]["captureState"] + { + failures + .push("coverage gap does not close against one non-captured row".to_owned()); + } + } + let has_retrying_fact = + transaction["observations"] + .as_array() + .is_some_and(|observations| { + observations + .iter() + .any(|observation| observation["disposition"] == "retrying") + }); + let target_source_missing = transaction["key"]["targetSiteCode"] + .as_str() + .is_none_or(|target_site| required_target_source_missing(manifest, target_site)); + let derived_confidence = if transaction["topologyCompatibility"] == "exact" + && transaction["timestampOrdering"] == "usable" + && transaction["terminalEvidence"] == true + && gap_ids.is_empty() + && !target_source_missing + { + "high" + } else if transaction["topologyCompatibility"] == "exact" + && transaction["timestampOrdering"] == "usable" + && transaction["terminalEvidence"] == false + && gap_ids.is_empty() + && has_retrying_fact + && !target_source_missing + { + "medium" + } else { + "low" + }; + if transaction["confidence"].as_str() != Some(derived_confidence) + || transaction["confidenceCeiling"].as_str() != Some(derived_confidence) + { + failures.push( + "transaction confidence and ceiling are not derived from facts/time/coverage" + .to_owned(), + ); + } + if transaction["confidence"] == "high" + && (transaction["confidenceCeiling"] != "high" + || transaction["topologyCompatibility"] != "exact" + || transaction["timestampOrdering"] != "usable" + || transaction["terminalEvidence"] != true + || !gap_ids.is_empty() + || target_source_missing) + { + failures + .push("high confidence bypasses topology/time/terminal/coverage gates".to_owned()); + } + let mut cited_gap_ids = BTreeSet::new(); + let observations = transaction["observations"].as_array(); + for observation in observations.into_iter().flatten() { + if !object_has_only( + observation, + &[ + "observationId", + "phase", + "disposition", + "terminal", + "evidence", + ], + ) { + failures.push("observation contains an unsupported field or shape".to_owned()); + } + if observation["observationId"] + .as_str() + .is_none_or(str::is_empty) + { + failures.push("transaction observation has an empty identity".to_owned()); + } + let references = observation["evidence"].as_array(); + if references.is_none_or(Vec::is_empty) { + failures.push("transaction observation lacks cited evidence".to_owned()); + } + for reference in references.into_iter().flatten() { + if !object_has_only(reference, &["artifactId", "startLine", "endLine"]) + || evidence_reference_key(reference).is_none() + { + failures.push("evidence reference is not exact and typed".to_owned()); + continue; + } + let reference_key = + evidence_reference_key(reference).expect("typed reference was checked"); + let Some(artifact) = artifact_by_id.get(reference_key.0.as_str()).copied() else { + failures.push( + "transaction evidence does not name one manifest artifact".to_owned(), + ); + continue; + }; + if artifact["captureState"] != "captured" + || artifact["rotation"]["fragmentComplete"] != true + { + cited_gap_ids.insert(reference_key.0.clone()); + } + let Some(record) = normalized.get(&reference_key) else { + failures.push( + "transaction evidence does not close against one logical record".to_owned(), + ); + continue; + }; + if !observation_matches_record(manifest, artifact, transaction, observation, record) + { + failures.push( + "transaction observation semantics diverge from cited evidence".to_owned(), + ); + } + if transaction["timestampOrdering"] == "usable" { + if let Err(error) = advance_usable_timestamp_sequence( + &mut prior_timestamp, + reference_key.0.as_str(), + record, + ) { + failures.push(format!("{transaction_id}: {error}")); + } + } + if transaction["confidence"] == "high" + && record.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + { + failures.push( + "high transaction cites evidence without usable timestamp provenance" + .to_owned(), + ); + } + } + } + let declared_gap_ids = gap_ids.iter().copied().collect::>(); + if cited_gap_ids + .iter() + .any(|artifact_id| !declared_gap_ids.contains(artifact_id.as_str())) + { + failures.push( + "cited incomplete coverage is missing from the derived transaction gaps".to_owned(), + ); + } + if transaction["confidence"] == "high" && !cited_gap_ids.is_empty() { + failures.push("high transaction cites incomplete coverage".to_owned()); + } + } + let observation_ids = transactions + .into_iter() + .flatten() + .flat_map(|transaction| transaction["observations"].as_array().into_iter().flatten()) + .filter_map(|observation| observation["observationId"].as_str()) + .collect::>(); + match expected_observation_ids(scenario) { + Some(expected_ids) if observation_ids.as_slice() == expected_ids => {} + Some(_) => failures.push("observation identity/cardinality matrix changed".to_owned()), + None => failures.push(format!( + "{scenario}: scenario is not registered in the observation identity matrix" + )), + } + let source_local_observations = expected["sourceLocalObservations"].as_array(); + for observation in source_local_observations.into_iter().flatten() { + if !object_has_only( + observation, + &[ + "observationId", + "classification", + "confidence", + "correlationEligible", + "artifactIds", + "evidence", + ], + ) || observation["observationId"] + .as_str() + .is_none_or(str::is_empty) + { + failures.push("source-local observation has an invalid shape or identity".to_owned()); + } + let artifact_id_values = observation["artifactIds"].as_array(); + let source_local_artifact_ids = artifact_id_values + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .filter(|artifact_id| !artifact_id.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + let mut sorted_source_local_artifact_ids = source_local_artifact_ids.clone(); + sorted_source_local_artifact_ids.sort_unstable(); + sorted_source_local_artifact_ids.dedup(); + if artifact_id_values.is_none_or(|values| { + values.len() != source_local_artifact_ids.len() || source_local_artifact_ids.is_empty() + }) || source_local_artifact_ids != sorted_source_local_artifact_ids + || source_local_artifact_ids + .iter() + .any(|artifact_id| !artifact_ids.contains(artifact_id)) + { + failures.push("source-local artifact IDs are not exact closed identities".to_owned()); + } + let source_local_artifacts = source_local_artifact_ids + .iter() + .map(|artifact_id| artifact_by_id.get(artifact_id).copied()) + .collect::>>() + .unwrap_or_default(); + let classification = observation["classification"].as_str(); + let exact_backing = match classification { + Some("coverageOnly") => { + !source_local_artifacts.is_empty() + && source_local_artifacts.iter().all(|artifact| { + artifact["captureState"] == "capped" + && artifact["rotation"]["fragmentComplete"] == false + }) + } + Some("rotationSplit") => { + let lineages = source_local_artifacts + .iter() + .filter_map(|artifact| artifact["rotation"]["lineageId"].as_str()) + .collect::>(); + let basenames = source_local_artifacts + .iter() + .filter_map(|artifact| artifact["originalBasename"].as_str()) + .collect::>(); + source_local_artifacts.len() == 2 + && lineages.len() == 1 + && basenames == BTreeSet::from(["sender.lo_", "sender.log"]) + && source_local_artifacts.iter().all(|artifact| { + artifact["captureState"] == "captured" + && artifact["rotation"]["fragmentComplete"] == false + }) + } + Some("topologyMismatch") => { + !source_local_artifacts.is_empty() + && source_local_artifacts.iter().all(|artifact| { + artifact["captureState"] == "captured" + && artifact["rotation"]["fragmentComplete"] == true + }) + } + _ => false, + }; + if observation["confidence"] != "low" + || observation["correlationEligible"] != false + || !exact_backing + { + failures.push( + "source-local observation exceeds its exact low-confidence backing".to_owned(), + ); + } + let references = observation["evidence"].as_array(); + if references.is_none() + || matches!(classification, Some("coverageOnly" | "rotationSplit")) + && references.is_some_and(|values| !values.is_empty()) + || classification == Some("topologyMismatch") && references.is_none_or(Vec::is_empty) + { + failures.push("source-local evidence does not match its classification".to_owned()); + } + for reference in references.into_iter().flatten() { + if !object_has_only(reference, &["artifactId", "startLine", "endLine"]) + || evidence_reference_key(reference).is_none() + { + failures.push("source-local evidence reference is not exact and typed".to_owned()); + continue; + } + let reference_key = + evidence_reference_key(reference).expect("typed reference was checked"); + if !source_local_artifact_ids.contains(&reference_key.0.as_str()) + || !normalized.contains_key(&reference_key) + { + failures.push( + "source-local evidence does not close against its declared artifacts" + .to_owned(), + ); + } + } + } + let source_local_ids = source_local_observations + .into_iter() + .flatten() + .filter_map(|observation| observation["observationId"].as_str()) + .collect::>(); + match expected_source_local_ids(scenario) { + Some(expected_ids) if source_local_ids.as_slice() == expected_ids => {} + Some(_) => failures.push("source-local identity/cardinality matrix changed".to_owned()), + None => failures.push(format!( + "{scenario}: scenario is not registered in the source-local identity matrix" + )), + } + + failures.extend(artifact_request_failures(scenario, manifest, expected)); + + failures +} + +#[test] +fn hierarchy_and_replication_scenario_matrix_is_exact() { + assert_eq!( + actual_scenarios().expect("hierarchy corpus root exists"), + SCENARIOS, + "the #331 scenario matrix changed" + ); + + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let expected = read_json(scenario, "expected.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + assert_eq!(manifest["scenario"], *scenario); + assert_eq!(expected["scenario"], *scenario); + assert_eq!(manifest["proposalOnly"], true); + assert_eq!(manifest["syntheticFixture"], true); + assert_eq!(expected["extractionProfile"]["profileId"], EXACT_PROFILE); + assert_eq!( + expected["stateChain"] + .as_array() + .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .as_deref(), + Some(STATE_CHAIN) + ); + } +} + +#[test] +fn hierarchy_candidates_are_deterministic_and_collision_resistant() { + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let artifacts = manifest["artifacts"] + .as_array() + .unwrap_or_else(|| panic!("{scenario}: artifacts are an array")); + let ids = artifacts + .iter() + .filter_map(|artifact| artifact["artifactId"].as_str()) + .collect::>(); + let mut sorted_ids = ids.clone(); + sorted_ids.sort_unstable(); + assert_eq!(ids, sorted_ids, "{scenario}: artifacts are stably sorted"); + assert_eq!( + ids.iter().copied().collect::>().len(), + ids.len(), + "{scenario}: artifact IDs are unique" + ); + + let canonical = hierarchy_candidate_bytes(scenario, &manifest) + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let mut reversed_manifest = manifest.clone(); + reversed_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are mutable") + .reverse(); + let reversed = hierarchy_candidate_bytes(scenario, &reversed_manifest) + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + assert_eq!( + canonical, reversed, + "{scenario}: input order changed byte-identical candidate output" + ); + } + + let manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let mut artifacts = manifest["artifacts"] + .as_array() + .expect("healthy artifacts are an array") + .clone(); + let mut collision = artifacts[1].clone(); + collision["artifactId"] = Value::String("healthy-05-sender-lo".to_owned()); + collision["originalBasename"] = Value::String("sender.lo_".to_owned()); + collision["sanitizedSourcePath"] = + Value::String("SYNTHETIC://configured-root/LAB/Logs/sender.lo_".to_owned()); + collision["pathFingerprint"] = Value::String("synthetic:healthy-sender-lo".to_owned()); + collision["rotation"] = serde_json::json!({ + "kind": "loUnderscore", + "lineageId": "healthy-sender-lo", + "fragmentComplete": true + }); + collision["relativePath"] = + Value::String("evidence/server-hierarchy-transfer/origin/lo_/sender.lo_".to_owned()); + artifacts.push(collision); + let mut collision_manifest = manifest.clone(); + collision_manifest["artifacts"] = Value::Array(artifacts.clone()); + let groups = hierarchy_candidate_groups("healthy-link", &collision_manifest) + .expect("candidates project"); + let exact_groups = groups + .iter() + .filter(|group| { + group.key.message_id == "msg-healthy-01" + && group.key.link_id == "link-lab-chd" + && group.key.origin_site_code == "LAB" + && group.key.target_site_code == "CHD" + }) + .collect::>(); + assert_eq!( + exact_groups.len(), + 1, + "same-key evidence must form one candidate group" + ); + let colliding_sender_facts = exact_groups[0] + .facts + .iter() + .filter(|fact| fact.phase == "send") + .collect::>(); + assert_eq!( + colliding_sender_facts.len(), + 2, + "same-key sender facts with distinct rotation provenance must both survive" + ); + assert_ne!( + colliding_sender_facts[0].artifact_id, + colliding_sender_facts[1].artifact_id + ); + assert_ne!( + colliding_sender_facts[0].rotation_kind, + colliding_sender_facts[1].rotation_kind + ); + let canonical = hierarchy_candidate_bytes("healthy-link", &collision_manifest) + .expect("candidates serialize"); + collision_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are mutable") + .reverse(); + let reversed = hierarchy_candidate_bytes("healthy-link", &collision_manifest) + .expect("candidates serialize"); + assert_eq!( + canonical, reversed, + "provenance collision changed canonical candidate bytes" + ); +} + +#[test] +fn generic_ccm_site_code_token_cannot_create_a_hierarchy_candidate() { + let scenario = "generic-site-token"; + let manifest = + read_json(scenario, "manifest.json").unwrap_or_else(|error| panic!("{scenario}: {error}")); + let expected = + read_json(scenario, "expected.json").unwrap_or_else(|error| panic!("{scenario}: {error}")); + let records = normalized_records(scenario, &manifest); + assert_eq!(records.len(), 1, "generic CCM evidence remains observable"); + let record = records.values().next().expect("generic evidence exists"); + assert!( + record.message.contains("CHD"), + "negative contains a site-code-looking token" + ); + assert!( + parse_fixture_fields(&record.message).is_err(), + "generic CCM text must not satisfy the exact hierarchy grammar" + ); + let candidates = + hierarchy_candidate_groups(scenario, &manifest).expect("generic artifacts project safely"); + assert!( + candidates.is_empty(), + "a site-code-looking token alone created a hierarchy candidate" + ); + assert_eq!(expected["transactions"], Value::Array(Vec::new())); +} + +#[test] +fn hierarchy_outputs_never_promote_coverage_or_time_to_cause() { + for scenario in SCENARIOS { + let expected = read_json(scenario, "expected.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + assert_eq!(expected["crossSideCausalClaims"], Value::Array(Vec::new())); + assert_eq!(expected["correlationHandoff"]["performed"], false); + assert_eq!(expected["correlationHandoff"]["timeOnlyEligible"], false); + + let coverage = expected["coverage"] + .as_array() + .unwrap_or_else(|| panic!("{scenario}: coverage is an array")); + for transaction in expected["transactions"] + .as_array() + .unwrap_or_else(|| panic!("{scenario}: transactions are an array")) + { + let confidence = transaction["confidence"].as_str().unwrap_or_default(); + let gaps = transaction["coverageGapArtifactIds"] + .as_array() + .unwrap_or_else(|| panic!("{scenario}: coverage gaps are an array")); + if confidence == "high" { + assert!( + gaps.is_empty(), + "{scenario}: high confidence retained a coverage gap" + ); + assert_eq!(transaction["topologyCompatibility"], "exact"); + assert_eq!(transaction["timestampOrdering"], "usable"); + assert_eq!(transaction["terminalEvidence"], true); + } + for gap in gaps { + let artifact_id = gap + .as_str() + .unwrap_or_else(|| panic!("{scenario}: gap ID is a string")); + let matches = coverage + .iter() + .filter(|row| row["artifactId"].as_str() == Some(artifact_id)) + .collect::>(); + assert_eq!( + matches.len(), + 1, + "{scenario}: gap must close against exactly one coverage row" + ); + assert!( + matches[0]["state"] + .as_str() + .and_then(coverage_state) + .is_some() + && matches[0]["state"] != "captured", + "{scenario}: gap row must have a typed non-captured state" + ); + } + } + } +} + +#[test] +fn hierarchy_manifest_sources_and_physical_evidence_are_bounded() { + let mut failures = Vec::new(); + for scenario in SCENARIOS { + let scenario_root = corpus_root().join(scenario); + let manifest = read_json(scenario, "manifest.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + if manifest["sccmManifestVersion"] != 1 + || manifest["bundle"]["bundleRole"] != "server" + || manifest["bundle"]["workflow"] != "hierarchyAndReplication" + { + failures.push(format!( + "{scenario}: manifest loses the additive server boundary" + )); + } + if DateTime::parse_from_rfc3339( + manifest["bundle"]["capturedUtc"] + .as_str() + .unwrap_or_default(), + ) + .is_err() + { + failures.push(format!("{scenario}: capture time is not RFC3339")); + } + let roles = manifest["topology"]["rolesObserved"].as_array(); + if roles.is_none_or(|values| { + values.len() != 1 || values.first().and_then(Value::as_str) != Some("siteServer") + }) { + failures.push(format!("{scenario}: topology roles are not exact strings")); + } + + let mut destinations = BTreeSet::new(); + let mut fingerprints = BTreeSet::new(); + let artifacts = manifest["artifacts"] + .as_array() + .unwrap_or_else(|| panic!("{scenario}: artifacts are an array")); + for artifact in artifacts { + let artifact_id = + required_string(artifact, "artifactId", scenario).unwrap_or(""); + let context = format!("{scenario}/{artifact_id}"); + let role = required_string(artifact, "producerRole", &context).unwrap_or("invalid"); + let basename = + required_string(artifact, "originalBasename", &context).unwrap_or("invalid"); + let direction = required_string(artifact, "direction", &context).unwrap_or("invalid"); + let state = required_string(artifact, "captureState", &context).unwrap_or("invalid"); + let source_id = required_string(artifact, "sourceId", &context).unwrap_or("invalid"); + if role != "siteServer" + || !matches!(direction, "origin" | "target") + || !matches!( + (source_id, basename), + ("server-hierarchy-control", "replmgr.log" | "rcmctrl.log") + | ( + "server-hierarchy-transfer", + "sender.log" | "sender.lo_" | "despool.log" + ) + ) + { + failures.push(format!("{context}: uncatalogued source tuple")); + } + let catalog = classify_artifact_name(basename, SccmRole::SiteServer); + if catalog.family != SccmArtifactFamily::Hierarchy || !catalog.uses_ccm_records { + failures.push(format!( + "{context}: source escapes the raw CCM hierarchy catalog" + )); + } + if !artifact_has_exact_public_provenance(artifact) + || !artifact_has_canonical_basename_rotation(artifact) + { + failures.push(format!("{context}: unsafe or empty provenance")); + } + if artifact["pathFingerprint"] + .as_str() + .map(str::to_ascii_lowercase) + .is_some_and(|value| !fingerprints.insert(value)) + { + failures.push(format!("{context}: duplicate physical fingerprint")); + } + let Some(coverage) = coverage_state(state) else { + failures.push(format!("{context}: unknown coverage state")); + continue; + }; + let Some(rotation) = rotation(&artifact["rotation"]) else { + failures.push(format!("{context}: invalid rotation shape")); + continue; + }; + let physical = matches!(state, "captured" | "capped" | "parseFailed"); + if physical { + let relative_path = artifact["relativePath"].as_str().unwrap_or_default(); + if !artifact_path_matches_basename(artifact, "relativePath", "evidence/") + || !destinations.insert(relative_path.to_ascii_lowercase()) + || artifact["encoding"] != "utf-8" + || artifact["bytesCopied"].as_u64().is_none() + || artifact["collectionLimit"]["byteLimit"].as_u64().is_none() + || artifact["collectionLimit"]["limitApplied"] + .as_bool() + .is_none() + { + failures.push(format!("{context}: invalid physical storage provenance")); + continue; + } + let path = scenario_root.join(relative_path); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) => { + failures.push(format!("{} is readable: {error}", path.display())); + continue; + } + }; + if artifact["bytesCopied"].as_u64() != Some(bytes.len() as u64) + || !String::from_utf8_lossy(&bytes).contains("SYNTHETIC FIXTURE") + { + failures.push(format!("{context}: physical bytes are not exact/synthetic")); + } + let model = SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: basename.to_owned(), + original_path: None, + host: artifact["producerHostHandle"].as_str().map(str::to_owned), + role: SccmRole::SiteServer, + configmgr_version: artifact["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: artifact["collectedUtc"].as_str().map(str::to_owned), + rotation, + coverage, + encoding: Some("utf-8".to_owned()), + }; + let normalized = normalize_ccm_artifact(model, &String::from_utf8_lossy(&bytes)); + if artifact["rotation"]["fragmentComplete"] == false && !normalized.is_empty() { + failures.push(format!( + "{context}: incomplete rotation fragment emitted a logical record" + )); + } + if artifact["rotation"]["fragmentComplete"] == true + && normalized.iter().any(|record| { + !record.message.contains("SYNTHETIC FIXTURE") + || record.reference.line_start.is_none() + || record.reference.line_end.is_none() + }) + { + failures.push(format!("{context}: logical evidence is not line-cited")); + } + } else if artifact.get("relativePath").is_some() + || artifact.get("bytesCopied").is_some() + || artifact.get("encoding").is_some() + || artifact.get("collectionLimit").is_some() + || artifact["rotation"].get("fragmentComplete").is_some() + { + failures.push(format!( + "{context}: nonphysical coverage invents file provenance" + )); + } + } + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn hierarchy_transactions_require_exact_keys_topology_time_and_citations() { + let mut failures = Vec::new(); + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let expected = read_json(scenario, "expected.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let records = normalized_records(scenario, &manifest); + let artifacts = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .filter_map(|artifact| { + Some(( + artifact["artifactId"].as_str()?.to_owned(), + artifact.clone(), + )) + }) + .collect::>(); + + let manifest_coverage = artifacts + .iter() + .filter_map(|(artifact_id, artifact)| { + Some(( + artifact_id.clone(), + artifact["captureState"].as_str()?.to_owned(), + )) + }) + .collect::>(); + let declared_coverage = expected["coverage"] + .as_array() + .expect("expected coverage is an array") + .iter() + .filter_map(|row| { + Some(( + row["artifactId"].as_str()?.to_owned(), + row["state"].as_str()?.to_owned(), + )) + }) + .collect::>(); + if manifest_coverage != declared_coverage { + failures.push(format!( + "{scenario}: coverage is not the exact manifest projection" + )); + } + + let transactions = expected["transactions"] + .as_array() + .expect("transactions are an array"); + let declared_target_sites = declared_target_site_codes(&manifest); + let transaction_ids = transactions + .iter() + .filter_map(|transaction| transaction["transactionId"].as_str()) + .collect::>(); + let mut sorted_transaction_ids = transaction_ids.clone(); + sorted_transaction_ids.sort_unstable(); + sorted_transaction_ids.dedup(); + if transaction_ids != sorted_transaction_ids { + failures.push(format!( + "{scenario}: transactions are not sorted and unique" + )); + } + + for transaction in transactions { + let transaction_id = transaction["transactionId"].as_str().unwrap_or(""); + let key = &transaction["key"]; + let key_fields = [ + ("MessageId", key["messageId"].as_str()), + ("LinkId", key["linkId"].as_str()), + ("OriginSite", key["originSiteCode"].as_str()), + ("TargetSite", key["targetSiteCode"].as_str()), + ("ProfileId", key["extractionProfileId"].as_str()), + ]; + if key_fields + .iter() + .any(|(_, value)| value.is_none_or(str::is_empty)) + || key["confidence"] != "exact" + || key["extractionProfileId"] != EXACT_PROFILE + { + failures.push(format!("{scenario}/{transaction_id}: key is not exact")); + continue; + } + if key["originSiteCode"].as_str() != manifest["topology"]["originSiteCode"].as_str() + || key["targetSiteCode"] + .as_str() + .is_none_or(|site| !declared_target_sites.contains(site)) + { + failures.push(format!( + "{scenario}/{transaction_id}: key is outside declared topology" + )); + } + let derived_id = format!( + "hierarchy:{}:{}:{}:{}", + key["messageId"].as_str().unwrap_or_default(), + key["originSiteCode"].as_str().unwrap_or_default(), + key["targetSiteCode"].as_str().unwrap_or_default(), + key["linkId"].as_str().unwrap_or_default() + ); + if transaction_id != derived_id { + failures.push(format!( + "{scenario}/{transaction_id}: ID is not key-derived" + )); + } + + let observations = transaction["observations"] + .as_array() + .expect("transaction observations are an array"); + let observation_ids = observations + .iter() + .filter_map(|observation| observation["observationId"].as_str()) + .collect::>(); + let mut sorted_observation_ids = observation_ids.clone(); + sorted_observation_ids.sort_unstable(); + sorted_observation_ids.dedup(); + if observation_ids != sorted_observation_ids || observation_ids.is_empty() { + failures.push(format!( + "{scenario}/{transaction_id}: observations are not exact sorted identities" + )); + } + + let mut prior_phase = 0usize; + let mut prior_timestamp = None; + let mut cited_terminal = false; + let mut cited_records = BTreeSet::new(); + for observation in observations { + let observation_id = observation["observationId"].as_str().unwrap_or(""); + let phase = observation["phase"].as_str().unwrap_or("invalid"); + let disposition = observation["disposition"].as_str().unwrap_or("invalid"); + let terminal = observation["terminal"].as_bool().unwrap_or(false); + let Some(phase_index) = + STATE_CHAIN.iter().position(|candidate| *candidate == phase) + else { + failures.push(format!("{scenario}/{observation_id}: unsupported phase")); + continue; + }; + if phase_index < prior_phase { + failures.push(format!( + "{scenario}/{transaction_id}: backward phase ordering" + )); + } + prior_phase = phase_index; + if !observation_disposition_is_coherent(disposition, terminal) { + failures.push(format!( + "{scenario}/{observation_id}: incoherent disposition/terminality" + )); + } + cited_terminal |= terminal; + + let references = observation["evidence"] + .as_array() + .expect("observation evidence is an array"); + if references.is_empty() { + failures.push(format!("{scenario}/{observation_id}: no evidence")); + } + for reference in references { + let artifact_id = reference["artifactId"].as_str().unwrap_or_default(); + let line_start = reference["startLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()); + let line_end = reference["endLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()); + let Some(record) = line_start.zip(line_end).and_then(|(start, end)| { + records.get(&(artifact_id.to_owned(), start, end)) + }) else { + failures.push(format!( + "{scenario}/{observation_id}: evidence is not a physical logical record" + )); + continue; + }; + if !cited_records.insert(( + artifact_id.to_owned(), + line_start.unwrap_or_default(), + line_end.unwrap_or_default(), + )) { + failures.push(format!( + "{scenario}/{transaction_id}: physical evidence reused" + )); + } + let Some(artifact) = artifacts.get(artifact_id) else { + failures.push(format!("{scenario}/{observation_id}: unknown artifact")); + continue; + }; + if !phase_is_owned_by( + artifact["originalBasename"].as_str().unwrap_or_default(), + phase, + ) { + failures.push(format!( + "{scenario}/{observation_id}: source cannot own phase {phase}" + )); + } + let fields = match parse_fixture_fields(&record.message) { + Ok(fields) => fields, + Err(error) => { + failures.push(format!("{scenario}/{observation_id}: {error}")); + continue; + } + }; + for (field, value) in &key_fields { + if fields.get(*field).map(String::as_str) != *value { + failures.push(format!( + "{scenario}/{observation_id}: evidence key {field} diverges" + )); + } + } + if fields.get("Phase").map(String::as_str) != Some(phase) + || fields.get("Disposition").map(String::as_str) != Some(disposition) + || fields.get("Terminal").map(String::as_str) + != Some(if terminal { "true" } else { "false" }) + { + failures.push(format!( + "{scenario}/{observation_id}: evidence semantics diverge" + )); + } + match transaction["timestampOrdering"].as_str() { + Some("usable") => { + if let Err(error) = advance_usable_timestamp_sequence( + &mut prior_timestamp, + artifact_id, + record, + ) { + failures.push(format!("{scenario}/{transaction_id}: {error}")); + } + } + Some("unusableInvalidOffset") => { + if record.timestamp.ordering_state + != SccmTimeOrderingState::OffsetInvalid + { + failures.push(format!( + "{scenario}/{transaction_id}: invalid offset was treated as usable" + )); + } + } + _ => failures.push(format!( + "{scenario}/{transaction_id}: unknown timestamp ordering" + )), + } + } + } + if transaction["terminalEvidence"].as_bool() != Some(cited_terminal) { + failures.push(format!( + "{scenario}/{transaction_id}: terminality is not citation-derived" + )); + } + } + + let outcome = transactions + .iter() + .map(|transaction| { + ( + transaction["state"].as_str(), + transaction["classification"].as_str(), + transaction["confidence"].as_str(), + ) + }) + .collect::>(); + let expected_outcome: &[(Option<&str>, Option<&str>, Option<&str>)] = match *scenario { + "absent-remote-source" + | "backlog-retry" + | "clock-offset-unknown" + | "receiver-processing-failure" + | "recovery" => &[( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + )], + "healthy-link" => &[(Some("succeeded"), Some("success"), Some("high"))], + "incomplete" | "rotation-boundary" => &[], + "topology-mismatch" => &[ + ( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + ), + ( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + ), + ], + "sender-failure" => &[ + ( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + ), + ( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + ), + ], + _ => &[], + }; + if outcome != expected_outcome { + failures.push(format!("{scenario}: outcome matrix changed")); + } + + if *scenario == "topology-mismatch" { + let fields = records + .values() + .map(|record| parse_fixture_fields(&record.message).expect("fields parse")) + .collect::>(); + if fields.len() != 2 + || fields[0].get("MessageId") != fields[1].get("MessageId") + || fields[0].get("LinkId") == fields[1].get("LinkId") + || fields[0].get("TargetSite") == fields[1].get("TargetSite") + { + failures.push( + "topology-mismatch: adversarial facts are not exact-key mismatches".to_owned(), + ); + } + } + if *scenario == "rotation-boundary" && !records.is_empty() { + failures.push("rotation-boundary: split fragments formed evidence".to_owned()); + } + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn hierarchy_gaps_requests_and_source_local_controls_are_bounded() { + let mut failures = Vec::new(); + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let expected = read_json(scenario, "expected.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let records = normalized_records(scenario, &manifest); + failures.extend(artifact_request_failures(scenario, &manifest, &expected)); + let requests = expected["artifactRequests"] + .as_array() + .expect("artifact requests are an array"); + + let request_reason_codes = requests + .iter() + .filter_map(|request| request["reasonCode"].as_str()) + .collect::>(); + let expected_reasons: &[&str] = match *scenario { + "absent-remote-source" => &[ + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + ], + "backlog-retry" => &[ + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + ], + "clock-offset-unknown" => &["missingTargetReceiveProcessApply"], + "incomplete" => &["coverageCapped"], + "receiver-processing-failure" | "recovery" => &["missingTargetReceiveProcessApply"], + "rotation-boundary" => &["coverageRotationSplit"], + "sender-failure" => &[ + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + ], + "topology-mismatch" => &[ + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + ], + _ => &[], + }; + if request_reason_codes != expected_reasons { + failures.push(format!("{scenario}: bounded request matrix changed")); + } + + let source_local = expected["sourceLocalObservations"] + .as_array() + .expect("source-local observations are an array"); + let source_local_classes = source_local + .iter() + .filter_map(|observation| observation["classification"].as_str()) + .collect::>(); + let expected_classes: &[&str] = match *scenario { + "incomplete" => &["coverageOnly"], + "rotation-boundary" => &["rotationSplit"], + _ => &[], + }; + if source_local_classes != expected_classes { + failures.push(format!("{scenario}: source-local control matrix changed")); + } + for observation in source_local { + if observation["confidence"] != "low" || observation["correlationEligible"] != false { + failures.push(format!( + "{scenario}: source-local evidence became correlatable" + )); + } + for reference in observation["evidence"] + .as_array() + .expect("source-local evidence is an array") + { + let key = ( + reference["artifactId"] + .as_str() + .unwrap_or_default() + .to_owned(), + reference["startLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or_default(), + reference["endLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or_default(), + ); + if !records.contains_key(&key) { + failures.push(format!( + "{scenario}: source-local evidence is not physically cited" + )); + } + } + } + } + + let contract = + include_str!("../../../docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md") + .split_whitespace() + .collect::>() + .join(" "); + for required in [ + "Raw CCM remains the transport grammar", + "timestamp proximity alone cannot create a transaction", + "A missing remote artifact is a coverage state, not evidence that the remote role is absent or broken", + "time alone is never eligible", + "not an acceptance source", + ] { + let required = required.split_whitespace().collect::>().join(" "); + if !contract.contains(&required) { + failures.push(format!("preparation document lost boundary: {required}")); + } + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn hierarchy_schema_and_identity_mutations_fail_closed() { + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let expected = read_json(scenario, "expected.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let failures = identity_and_schema_failures(scenario, &manifest, &expected); + assert!( + failures.is_empty(), + "{scenario}: committed schema is invalid: {failures:?}" + ); + } + + let healthy_manifest = read_json("healthy-link", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-link", "expected.json").expect("expected loads"); + let absent_manifest = + read_json("absent-remote-source", "manifest.json").expect("manifest loads"); + let absent_expected = + read_json("absent-remote-source", "expected.json").expect("expected loads"); + let clock_manifest = + read_json("clock-offset-unknown", "manifest.json").expect("manifest loads"); + let clock_expected = + read_json("clock-offset-unknown", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut unknown_manifest_field = healthy_manifest.clone(); + unknown_manifest_field["serverRootCause"] = Value::String("network".to_owned()); + if identity_and_schema_failures("healthy-link", &unknown_manifest_field, &healthy_expected) + .is_empty() + { + accepted.push("unknown manifest cause field"); + } + + let mut non_string_role = healthy_manifest.clone(); + non_string_role["topology"]["rolesObserved"] + .as_array_mut() + .expect("roles are mutable") + .push(Value::from(7)); + if identity_and_schema_failures("healthy-link", &non_string_role, &healthy_expected).is_empty() + { + accepted.push("non-string topology role"); + } + + let mut aliased_destination = healthy_manifest.clone(); + aliased_destination["artifacts"][1]["relativePath"] = + Value::String("evidence/server-hierarchy-control/origin/current/./replmgr.log".to_owned()); + if identity_and_schema_failures("healthy-link", &aliased_destination, &healthy_expected) + .is_empty() + { + accepted.push("dot-segment evidence alias"); + } + + let mut unsafe_source = healthy_manifest.clone(); + unsafe_source["artifacts"][0]["sanitizedSourcePath"] = + Value::String("SYNTHETIC://../../Users/Real/replmgr.log".to_owned()); + if identity_and_schema_failures("healthy-link", &unsafe_source, &healthy_expected).is_empty() { + accepted.push("unsafe sanitized source path"); + } + + let mut empty_fingerprint = healthy_manifest.clone(); + empty_fingerprint["artifacts"][0]["pathFingerprint"] = Value::String("synthetic:".to_owned()); + if identity_and_schema_failures("healthy-link", &empty_fingerprint, &healthy_expected) + .is_empty() + { + accepted.push("empty path fingerprint"); + } + + let mut unknown_version = healthy_manifest.clone(); + unknown_version["artifacts"][0]["sourceVersion"] = Value::String("9.99.UNKNOWN".to_owned()); + if identity_and_schema_failures("healthy-link", &unknown_version, &healthy_expected).is_empty() + { + accepted.push("unknown source version retained selected profile"); + } + + let mut nonphysical_file = absent_manifest.clone(); + nonphysical_file["artifacts"][1]["relativePath"] = Value::Bool(false); + if identity_and_schema_failures("absent-remote-source", &nonphysical_file, &absent_expected) + .is_empty() + { + accepted.push("nonphysical coverage invented a malformed path"); + } + + let mut non_string_state = healthy_expected.clone(); + non_string_state["stateChain"] + .as_array_mut() + .expect("state chain is mutable") + .push(Value::from(7)); + if identity_and_schema_failures("healthy-link", &healthy_manifest, &non_string_state).is_empty() + { + accepted.push("non-string state-chain entry"); + } + + let mut missing_observation = healthy_expected.clone(); + missing_observation["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are mutable") + .remove(1); + if identity_and_schema_failures("healthy-link", &healthy_manifest, &missing_observation) + .is_empty() + { + accepted.push("required observation deleted"); + } + + let mut mismatched_key = healthy_expected.clone(); + mismatched_key["transactions"][0]["key"]["targetSiteCode"] = Value::String("SEC".to_owned()); + if identity_and_schema_failures("healthy-link", &healthy_manifest, &mismatched_key).is_empty() { + accepted.push("transaction ID diverged from immutable topology key"); + } + + let mut undeclared_topology_key = healthy_expected.clone(); + undeclared_topology_key["transactions"][0]["key"]["targetSiteCode"] = + Value::String("SEC".to_owned()); + undeclared_topology_key["transactions"][0]["transactionId"] = + Value::String("hierarchy:MSG-HEALTHY-001:LAB:SEC:LINK-LAB-CHD".to_owned()); + if identity_and_schema_failures("healthy-link", &healthy_manifest, &undeclared_topology_key) + .is_empty() + { + accepted.push("transaction key target is outside manifest topology"); + } + + let mut time_only_high = clock_expected.clone(); + time_only_high["transactions"][0]["confidence"] = Value::String("high".to_owned()); + time_only_high["transactions"][0]["confidenceCeiling"] = Value::String("high".to_owned()); + if identity_and_schema_failures("clock-offset-unknown", &clock_manifest, &time_only_high) + .is_empty() + { + accepted.push("invalid-offset transaction became high confidence"); + } + + let mut origin_evidence_on_target_host = healthy_manifest.clone(); + origin_evidence_on_target_host["artifacts"][1]["producerHostHandle"] = + healthy_manifest["topology"]["targetHostHandle"].clone(); + if identity_and_schema_failures( + "healthy-link", + &origin_evidence_on_target_host, + &healthy_expected, + ) + .is_empty() + { + accepted.push("origin evidence retained a target host"); + } + + let mut target_evidence_on_origin_host = healthy_manifest.clone(); + target_evidence_on_origin_host["artifacts"][2]["producerHostHandle"] = + healthy_manifest["topology"]["originHostHandle"].clone(); + if identity_and_schema_failures( + "healthy-link", + &target_evidence_on_origin_host, + &healthy_expected, + ) + .is_empty() + { + accepted.push("target evidence retained an origin host"); + } + + let mismatch_manifest = + read_json("topology-mismatch", "manifest.json").expect("manifest loads"); + let mismatch_expected = + read_json("topology-mismatch", "expected.json").expect("expected loads"); + let mut additional_target_on_primary_host = mismatch_manifest.clone(); + additional_target_on_primary_host["artifacts"][1]["producerHostHandle"] = + mismatch_manifest["topology"]["targetHostHandle"].clone(); + if identity_and_schema_failures( + "topology-mismatch", + &additional_target_on_primary_host, + &mismatch_expected, + ) + .is_empty() + { + accepted.push("additional-target evidence retained the primary target host"); + } + + let mut fabricated_gap = absent_expected.clone(); + fabricated_gap["transactions"][0]["coverageGapArtifactIds"][0] = + Value::String("unknown-artifact".to_owned()); + if identity_and_schema_failures("absent-remote-source", &absent_manifest, &fabricated_gap) + .is_empty() + { + accepted.push("fabricated coverage-gap artifact ID"); + } + + let mut missing_coverage_row = absent_expected.clone(); + missing_coverage_row["coverage"] + .as_array_mut() + .expect("coverage is mutable") + .remove(1); + if identity_and_schema_failures( + "absent-remote-source", + &absent_manifest, + &missing_coverage_row, + ) + .is_empty() + { + accepted.push("missing manifest coverage row"); + } + + let mut duplicate_coverage_row = absent_expected.clone(); + let repeated_row = duplicate_coverage_row["coverage"][1].clone(); + duplicate_coverage_row["coverage"] + .as_array_mut() + .expect("coverage is mutable") + .push(repeated_row); + if identity_and_schema_failures( + "absent-remote-source", + &absent_manifest, + &duplicate_coverage_row, + ) + .is_empty() + { + accepted.push("duplicate manifest coverage row"); + } + + let mut unknown_coverage_row = absent_expected.clone(); + unknown_coverage_row["coverage"] + .as_array_mut() + .expect("coverage is mutable") + .push(serde_json::json!({ + "artifactId": "unknown-artifact", + "state": "absent" + })); + if identity_and_schema_failures( + "absent-remote-source", + &absent_manifest, + &unknown_coverage_row, + ) + .is_empty() + { + accepted.push("unknown manifest coverage row"); + } + + let mut malformed_coverage_row = absent_expected.clone(); + malformed_coverage_row["coverage"][1]["unexpected"] = Value::Bool(true); + if identity_and_schema_failures( + "absent-remote-source", + &absent_manifest, + &malformed_coverage_row, + ) + .is_empty() + { + accepted.push("malformed manifest coverage row"); + } + + let mut causal_claim = healthy_expected.clone(); + causal_claim["crossSideCausalClaims"] = + Value::Array(vec![Value::String("same-time client impact".to_owned())]); + if identity_and_schema_failures("healthy-link", &healthy_manifest, &causal_claim).is_empty() { + accepted.push("cross-side causal claim"); + } + + assert!( + accepted.is_empty(), + "hierarchy contract accepted adversarial mutations: {accepted:?}" + ); +} + +#[test] +fn hierarchy_artifact_request_mutations_fail_closed() { + let manifest = + read_json("clock-offset-unknown", "manifest.json").expect("clock manifest loads"); + let expected = + read_json("clock-offset-unknown", "expected.json").expect("clock expected loads"); + assert!( + identity_and_schema_failures("clock-offset-unknown", &manifest, &expected).is_empty(), + "the committed missing-target request is the bounded control" + ); + assert!( + artifact_request_failures("clock-offset-unknown", &manifest, &expected).is_empty(), + "the shared request loader accepts the exact target-scoped control" + ); + + let mutations = [ + ( + "wrong source ID", + "sourceId", + serde_json::json!("server-hierarchy-transfer"), + ), + ("wrong direction", "direction", serde_json::json!("origin")), + ( + "undeclared target site", + "targetSiteCode", + serde_json::json!("XYZ"), + ), + ( + "wrong source basename", + "basenames", + serde_json::json!(["replmgr.log"]), + ), + ( + "extra origin basename", + "basenames", + serde_json::json!(["rcmctrl.log", "replmgr.log"]), + ), + ( + "wrong reason", + "reasonCode", + serde_json::json!("invalidOffset"), + ), + ]; + + let mut accepted = Vec::new(); + for (label, field, value) in mutations { + let mut mutated = expected.clone(); + mutated["artifactRequests"][0][field] = value; + if artifact_request_failures("clock-offset-unknown", &manifest, &mutated).is_empty() + || identity_and_schema_failures("clock-offset-unknown", &manifest, &mutated).is_empty() + { + accepted.push(label); + } + } + + assert!( + accepted.is_empty(), + "artifact request provenance mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn hierarchy_review_4826191775_mutations_fail_closed() { + let absent_manifest = + read_json("absent-remote-source", "manifest.json").expect("absent manifest loads"); + let absent_expected = + read_json("absent-remote-source", "expected.json").expect("absent expected loads"); + let incomplete_manifest = + read_json("incomplete", "manifest.json").expect("incomplete manifest loads"); + let incomplete_expected = + read_json("incomplete", "expected.json").expect("incomplete expected loads"); + let rotation_manifest = + read_json("rotation-boundary", "manifest.json").expect("rotation manifest loads"); + let rotation_expected = + read_json("rotation-boundary", "expected.json").expect("rotation expected loads"); + let healthy_manifest = + read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let healthy_expected = + read_json("healthy-link", "expected.json").expect("healthy expected loads"); + + let mut accepted = Vec::new(); + let mut audit = |label: &'static str, scenario: &str, manifest: &Value, expected: &Value| { + if identity_and_schema_failures(scenario, manifest, expected).is_empty() { + accepted.push(label); + } + }; + + let mut absent_both = absent_expected.clone(); + absent_both["artifactRequests"][0]["direction"] = serde_json::json!("both"); + audit( + "target-only absent request broadened to both", + "absent-remote-source", + &absent_manifest, + &absent_both, + ); + + let mut capped_both = incomplete_expected.clone(); + capped_both["artifactRequests"][0]["direction"] = serde_json::json!("both"); + audit( + "origin-only capped request broadened to both", + "incomplete", + &incomplete_manifest, + &capped_both, + ); + + let mut rotation_both = rotation_expected.clone(); + rotation_both["artifactRequests"][0]["direction"] = serde_json::json!("both"); + audit( + "origin-only rotation request broadened to both", + "rotation-boundary", + &rotation_manifest, + &rotation_both, + ); + + let mut wrong_declared_site_manifest = absent_manifest.clone(); + wrong_declared_site_manifest["topology"]["additionalTargets"] = serde_json::json!([{ + "siteCode": "SEC", + "hostHandle": "safe:server:lab-sec-01" + }]); + let mut wrong_declared_site_expected = absent_expected.clone(); + wrong_declared_site_expected["artifactRequests"][0]["targetSiteCode"] = + serde_json::json!("SEC"); + audit( + "coverage request targeted a different declared site", + "absent-remote-source", + &wrong_declared_site_manifest, + &wrong_declared_site_expected, + ); + + let mut wrong_coverage_host_manifest = absent_manifest.clone(); + wrong_coverage_host_manifest["topology"]["additionalTargets"] = serde_json::json!([{ + "siteCode": "SEC", + "hostHandle": "safe:server:lab-sec-01" + }]); + wrong_coverage_host_manifest["artifacts"][1]["producerHostHandle"] = + serde_json::json!("safe:server:lab-sec-01"); + audit( + "coverage artifact moved to a different target host", + "absent-remote-source", + &wrong_coverage_host_manifest, + &absent_expected, + ); + + let mut split_lineage = rotation_manifest.clone(); + split_lineage["artifacts"][1]["rotation"]["lineageId"] = serde_json::json!("unrelated-lineage"); + audit( + "rotation request joined unrelated lineages", + "rotation-boundary", + &split_lineage, + &rotation_expected, + ); + + let mut wrong_rotation_identity = rotation_manifest.clone(); + wrong_rotation_identity["artifacts"][1]["rotation"] = serde_json::json!({ + "kind": "numbered", + "value": 1, + "lineageId": "rotation-sender", + "fragmentComplete": false + }); + audit( + "sender.lo_ declared as a numbered rotation", + "rotation-boundary", + &wrong_rotation_identity, + &rotation_expected, + ); + + let mut out_of_profile_version = healthy_manifest.clone(); + out_of_profile_version["artifacts"][2]["sourceVersion"] = serde_json::json!("5.00.TEST.9999"); + audit( + "unadmitted source version retained selected profile", + "healthy-link", + &out_of_profile_version, + &healthy_expected, + ); + + let mut noncanonical_version = healthy_manifest.clone(); + noncanonical_version["artifacts"][2]["sourceVersion"] = + serde_json::json!("5.00.TEST.not-canonical"); + audit( + "noncanonical source version retained selected profile", + "healthy-link", + &noncanonical_version, + &healthy_expected, + ); + + let mut invalid_collection_time = healthy_manifest.clone(); + invalid_collection_time["artifacts"][2]["collectedUtc"] = serde_json::json!("not-a-timestamp"); + audit( + "invalid collection timestamp retained exact output", + "healthy-link", + &invalid_collection_time, + &healthy_expected, + ); + + let mut empty_target_handle = healthy_manifest.clone(); + empty_target_handle["topology"]["targetHostHandle"] = serde_json::json!("safe:server:"); + empty_target_handle["artifacts"][2]["producerHostHandle"] = serde_json::json!("safe:server:"); + empty_target_handle["artifacts"][3]["producerHostHandle"] = serde_json::json!("safe:server:"); + audit( + "empty safe target-handle payload retained exact topology", + "healthy-link", + &empty_target_handle, + &healthy_expected, + ); + + let mut colliding_hosts = healthy_manifest.clone(); + let origin_host = colliding_hosts["topology"]["originHostHandle"].clone(); + colliding_hosts["topology"]["targetHostHandle"] = origin_host.clone(); + colliding_hosts["artifacts"][2]["producerHostHandle"] = origin_host.clone(); + colliding_hosts["artifacts"][3]["producerHostHandle"] = origin_host; + audit( + "origin and target sites shared one host handle", + "healthy-link", + &colliding_hosts, + &healthy_expected, + ); + + let mut empty_identity_manifest = absent_manifest.clone(); + empty_identity_manifest["artifacts"][1]["artifactId"] = serde_json::json!(""); + empty_identity_manifest["artifacts"] + .as_array_mut() + .expect("artifact array is mutable") + .swap(0, 1); + let mut empty_identity_expected = absent_expected.clone(); + empty_identity_expected["coverage"][1]["artifactId"] = serde_json::json!(""); + empty_identity_expected["coverage"] + .as_array_mut() + .expect("coverage array is mutable") + .swap(0, 1); + empty_identity_expected["transactions"][0]["coverageGapArtifactIds"][0] = serde_json::json!(""); + audit( + "empty artifact and gap identity retained exact coverage", + "absent-remote-source", + &empty_identity_manifest, + &empty_identity_expected, + ); + + assert!( + accepted.is_empty(), + "review 4826191775 mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn hierarchy_review_4826454819_mutations_fail_closed() { + let healthy_manifest = + read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let healthy_expected = + read_json("healthy-link", "expected.json").expect("healthy expected loads"); + let incomplete_manifest = + read_json("incomplete", "manifest.json").expect("incomplete manifest loads"); + let incomplete_expected = + read_json("incomplete", "expected.json").expect("incomplete expected loads"); + let absent_manifest = + read_json("absent-remote-source", "manifest.json").expect("absent manifest loads"); + let absent_expected = + read_json("absent-remote-source", "expected.json").expect("absent expected loads"); + + let mut accepted = Vec::new(); + let mut candidate_acceptances = Vec::new(); + { + let mut audit = + |label: &'static str, scenario: &str, manifest: &Value, expected: &Value| { + if identity_and_schema_failures(scenario, manifest, expected).is_empty() { + accepted.push(label); + } + }; + + let mut capped_terminal_manifest = healthy_manifest.clone(); + capped_terminal_manifest["artifacts"][3]["captureState"] = serde_json::json!("capped"); + let mut capped_terminal_expected = healthy_expected.clone(); + capped_terminal_expected["coverage"][3]["state"] = serde_json::json!("capped"); + audit( + "high transaction cited capped terminal evidence without a gap", + "healthy-link", + &capped_terminal_manifest, + &capped_terminal_expected, + ); + + let mut denied_terminal_manifest = healthy_manifest.clone(); + denied_terminal_manifest["artifacts"][3]["captureState"] = + serde_json::json!("accessDenied"); + let denied_terminal_artifact = denied_terminal_manifest["artifacts"][3] + .as_object_mut() + .expect("terminal artifact is mutable"); + for physical_field in ["relativePath", "bytesCopied", "encoding", "collectionLimit"] { + denied_terminal_artifact.remove(physical_field); + } + denied_terminal_artifact["rotation"] + .as_object_mut() + .expect("terminal rotation is mutable") + .remove("fragmentComplete"); + let mut denied_terminal_expected = healthy_expected.clone(); + denied_terminal_expected["coverage"][3]["state"] = serde_json::json!("accessDenied"); + audit( + "high transaction cited access-denied terminal evidence without a gap", + "healthy-link", + &denied_terminal_manifest, + &denied_terminal_expected, + ); + + let mut partial_terminal_manifest = healthy_manifest.clone(); + partial_terminal_manifest["artifacts"][3]["rotation"]["fragmentComplete"] = + serde_json::json!(false); + audit( + "high transaction cited an incomplete terminal fragment without a gap", + "healthy-link", + &partial_terminal_manifest, + &healthy_expected, + ); + + let mut relabeled_failure = healthy_expected.clone(); + let terminal_observation = &mut relabeled_failure["transactions"][0]["observations"][6]; + terminal_observation["disposition"] = serde_json::json!("failed"); + terminal_observation["evidence"] = serde_json::json!([{ + "artifactId": "healthy-02-sender", + "startLine": 1, + "endLine": 1 + }]); + relabeled_failure["transactions"][0]["state"] = serde_json::json!("failed"); + relabeled_failure["transactions"][0]["classification"] = + serde_json::json!("confirmedFailure"); + audit( + "successful evidence was relabeled and recited as a confirmed failure", + "healthy-link", + &healthy_manifest, + &relabeled_failure, + ); + + let mut sender_moved_to_target = healthy_manifest.clone(); + sender_moved_to_target["artifacts"][1]["direction"] = serde_json::json!("target"); + sender_moved_to_target["artifacts"][1]["producerHostHandle"] = + healthy_manifest["topology"]["targetHostHandle"].clone(); + audit( + "origin sender evidence moved to the target direction", + "healthy-link", + &sender_moved_to_target, + &healthy_expected, + ); + + let mut sender_wrong_source = healthy_manifest.clone(); + sender_wrong_source["artifacts"][1]["sourceId"] = + serde_json::json!("server-hierarchy-control"); + audit( + "sender evidence was relabeled as a control source", + "healthy-link", + &sender_wrong_source, + &healthy_expected, + ); + + let mut sender_wrong_basename = healthy_manifest.clone(); + sender_wrong_basename["artifacts"][1]["originalBasename"] = + serde_json::json!("despool.log"); + audit( + "origin sender evidence was relabeled with a target-only basename", + "healthy-link", + &sender_wrong_basename, + &healthy_expected, + ); + + let mut out_of_profile_sender = healthy_manifest.clone(); + out_of_profile_sender["artifacts"][1]["sourceVersion"] = + serde_json::json!("5.00.TEST.9999"); + if hierarchy_candidate_groups("healthy-link", &out_of_profile_sender) + .expect("candidate projection remains deterministic") + .iter() + .flat_map(|group| &group.facts) + .any(|fact| fact.artifact_id == "healthy-02-sender") + { + candidate_acceptances.push("out-of-profile sender entered an exact candidate"); + } + + let mut partial_sender = healthy_manifest.clone(); + partial_sender["artifacts"][1]["rotation"]["fragmentComplete"] = serde_json::json!(false); + if hierarchy_candidate_groups("healthy-link", &partial_sender) + .expect("candidate projection remains deterministic") + .iter() + .flat_map(|group| &group.facts) + .any(|fact| fact.artifact_id == "healthy-02-sender") + { + candidate_acceptances.push("incomplete sender fragment entered an exact candidate"); + } + + let mut escalated_source_local = incomplete_expected.clone(); + escalated_source_local["sourceLocalObservations"][0]["confidence"] = + serde_json::json!("high"); + escalated_source_local["sourceLocalObservations"][0]["correlationEligible"] = + serde_json::json!(true); + audit( + "capped source-local evidence became high and correlation eligible", + "incomplete", + &incomplete_manifest, + &escalated_source_local, + ); + + let mut missing_required_request = absent_expected.clone(); + missing_required_request["artifactRequests"] = serde_json::json!([]); + audit( + "required absent-coverage request was deleted", + "absent-remote-source", + &absent_manifest, + &missing_required_request, + ); + + let mut production_labeled_fixture = healthy_manifest.clone(); + production_labeled_fixture["proposalOnly"] = serde_json::json!(false); + production_labeled_fixture["syntheticFixture"] = serde_json::json!(false); + audit( + "synthetic proposal fixture was relabeled as production evidence", + "healthy-link", + &production_labeled_fixture, + &healthy_expected, + ); + } + accepted.extend(candidate_acceptances); + + assert!( + accepted.is_empty(), + "review 4826454819 mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn hierarchy_coderabbit_5ead896_target_topology_requires_present_handles() { + let mut manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let mut target_artifact = manifest["artifacts"][2].clone(); + manifest["topology"] + .as_object_mut() + .expect("topology is mutable") + .remove("targetHostHandle"); + target_artifact + .as_object_mut() + .expect("target artifact is mutable") + .remove("producerHostHandle"); + let fields = BTreeMap::from([ + ("OriginSite".to_owned(), "LAB".to_owned()), + ("TargetSite".to_owned(), "CHD".to_owned()), + ]); + + assert!( + !record_matches_topology(&manifest, &target_artifact, &fields), + "two absent target handles cannot satisfy exact topology" + ); +} + +#[test] +fn hierarchy_coderabbit_878a051_absent_target_rcmctrl_remains_missing() { + let mut manifest = + read_json("absent-remote-source", "manifest.json").expect("absent manifest loads"); + let expected = + read_json("absent-remote-source", "expected.json").expect("absent expected loads"); + manifest["artifacts"][1]["sourceId"] = serde_json::json!("server-hierarchy-control"); + manifest["artifacts"][1]["originalBasename"] = serde_json::json!("rcmctrl.log"); + + let failures = artifact_request_failures("absent-remote-source", &manifest, &expected); + assert!( + failures.is_empty(), + "target-side absent rcmctrl remains an exact missing request: {failures:?}" + ); +} + +#[test] +fn hierarchy_coderabbit_878a051_shared_predicates_stay_narrow() { + let manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let artifact = manifest["artifacts"][1].clone(); + assert!(artifact_has_exact_public_provenance(&artifact)); + + let mut unsafe_host = artifact.clone(); + unsafe_host["producerHostHandle"] = serde_json::json!("safe:server:bad/path"); + assert!(!artifact_has_exact_public_provenance(&unsafe_host)); + + let mut unadmitted_version = artifact; + unadmitted_version["sourceVersion"] = serde_json::json!("5.00.TEST.9999"); + assert!(!artifact_has_exact_public_provenance(&unadmitted_version)); + + for (disposition, terminal) in [ + ("succeeded", false), + ("succeeded", true), + ("failed", true), + ("retrying", false), + ] { + assert!(observation_disposition_is_coherent(disposition, terminal)); + } + assert!( + !observation_disposition_is_coherent("deferred", false), + "deferred is a transaction state, not a source observation disposition" + ); +} + +#[test] +fn hierarchy_coderabbit_0a6ba32_rotation_uses_shared_wire_kind() { + let manifest = + read_json("rotation-boundary", "manifest.json").expect("rotation manifest loads"); + let archived = &manifest["artifacts"][1]; + + assert_eq!( + archived["rotation"]["kind"], "loUnderscore", + "the .lo_ filename uses the shared SccmRotation wire tag" + ); + assert_eq!( + rotation(&archived["rotation"]), + Some(SccmRotation::LoUnderscore) + ); + + let mut legacy_kind = archived["rotation"].clone(); + legacy_kind["kind"] = serde_json::json!("lo_"); + assert!( + rotation(&legacy_kind).is_none(), + "the #331 fixture contract must not preserve a private rotation alias" + ); +} + +#[test] +fn hierarchy_coderabbit_0a6ba32_identity_matrices_reject_unknown_scenarios() { + let unknown = "future-unregistered-scenario"; + + assert_ne!( + expected_transaction_ids(unknown), + expected_transaction_ids("generic-site-token") + ); + assert_ne!( + expected_observation_ids(unknown), + expected_observation_ids("generic-site-token") + ); + assert_ne!( + expected_source_local_ids(unknown), + expected_source_local_ids("healthy-link") + ); +} + +#[test] +fn hierarchy_coderabbit_d78dc49_complete_lo_owns_send_phase() { + let mut manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + manifest["artifacts"][1]["originalBasename"] = serde_json::json!("sender.lo_"); + manifest["artifacts"][1]["sanitizedSourcePath"] = + serde_json::json!("SYNTHETIC://configured-root/LAB/Logs/sender.lo_"); + manifest["artifacts"][1]["pathFingerprint"] = serde_json::json!("synthetic:healthy-sender-lo"); + manifest["artifacts"][1]["rotation"]["kind"] = serde_json::json!("loUnderscore"); + manifest["artifacts"][1]["relativePath"] = + serde_json::json!("evidence/server-hierarchy-transfer/origin/lo_/sender.lo_"); + + assert!(artifact_is_exact_candidate( + &manifest, + &manifest["artifacts"][1] + )); + + let groups = hierarchy_candidate_groups("healthy-link", &manifest) + .expect("candidate projection remains deterministic"); + assert!( + groups.iter().flat_map(|group| &group.facts).any(|fact| { + fact.artifact_id == "healthy-02-sender" + && fact.phase == "send" + && fact.rotation_kind == "loUnderscore" + }), + "a complete admitted sender.lo_ record must not be silently skipped" + ); + + let mut relabeled_basename = + read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + relabeled_basename["artifacts"][1]["originalBasename"] = serde_json::json!("sender.lo_"); + assert!( + !artifact_is_exact_candidate(&relabeled_basename, &relabeled_basename["artifacts"][1]), + "a basename-only relabel cannot create a rotated candidate" + ); + + let mut mismatched_relative_path = manifest.clone(); + mismatched_relative_path["artifacts"][1]["relativePath"] = + serde_json::json!("evidence/server-hierarchy-transfer/origin/current/sender.log"); + assert!( + !artifact_is_exact_candidate( + &mismatched_relative_path, + &mismatched_relative_path["artifacts"][1] + ), + "relativePath must close against originalBasename" + ); + + let mut mismatched_sanitized_path = manifest.clone(); + mismatched_sanitized_path["artifacts"][1]["sanitizedSourcePath"] = + serde_json::json!("SYNTHETIC://configured-root/LAB/Logs/sender.log"); + assert!( + !artifact_is_exact_candidate( + &mismatched_sanitized_path, + &mismatched_sanitized_path["artifacts"][1] + ), + "sanitizedSourcePath must close against originalBasename" + ); + + let mut mismatched_rotation = manifest; + mismatched_rotation["artifacts"][1]["rotation"]["kind"] = serde_json::json!("current"); + assert!( + !artifact_is_exact_candidate(&mismatched_rotation, &mismatched_rotation["artifacts"][1]), + "the sender.lo_ basename requires the canonical loUnderscore rotation" + ); +} + +#[test] +fn hierarchy_nested_output_contract_rejects_hidden_causality_and_raw_paths() { + let manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let expected = read_json("healthy-link", "expected.json").expect("healthy expected loads"); + let mutations = [ + ( + "analysis contract accepted a server root-cause claim", + "analysisContract", + "serverRootCause", + serde_json::json!("remote site failed"), + ), + ( + "extraction profile accepted a raw configured path", + "extractionProfile", + "rawConfiguredPath", + serde_json::json!(r"C:\Users\alice\Logs"), + ), + ( + "correlation handoff accepted a time-only cause", + "correlationHandoff", + "timeOnlyCause", + serde_json::json!("same-minute outage"), + ), + ]; + let mut accepted = Vec::new(); + + for (label, object, field, value) in mutations { + let mut mutated = expected.clone(); + mutated[object][field] = value; + if identity_and_schema_failures("healthy-link", &manifest, &mutated).is_empty() { + accepted.push(label); + } + } + + assert!( + accepted.is_empty(), + "nested output schema accepted unsupported fields: {accepted:?}" + ); +} + +#[test] +fn hierarchy_candidate_identity_and_provenance_are_safe_bounded_opaque_ids() { + let manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let mutations = [ + ("empty artifact ID", "artifactId", serde_json::json!("")), + ( + "oversized artifact ID", + "artifactId", + serde_json::json!("a".repeat(129)), + ), + ( + "path-shaped fingerprint", + "pathFingerprint", + serde_json::json!(r"synthetic:C:\Users\alice\sender.log"), + ), + ( + "oversized fingerprint", + "pathFingerprint", + serde_json::json!(format!("synthetic:{}", "a".repeat(129))), + ), + ( + "path-shaped rotation lineage", + "rotation.lineageId", + serde_json::json!(r"C:\Users\alice\sender.log"), + ), + ( + "oversized rotation lineage", + "rotation.lineageId", + serde_json::json!("a".repeat(129)), + ), + ]; + let mut accepted = Vec::new(); + + for (label, field, value) in mutations { + let mut mutated = manifest.clone(); + if field == "rotation.lineageId" { + mutated["artifacts"][1]["rotation"]["lineageId"] = value; + } else { + mutated["artifacts"][1][field] = value; + } + if artifact_is_exact_candidate(&mutated, &mutated["artifacts"][1]) { + accepted.push(label); + } + } + + assert!( + accepted.is_empty(), + "unsafe candidate identity/provenance values were admitted: {accepted:?}" + ); +} + +fn exact_sender_projection( + message_id: &str, + link_id: &str, + origin_site: &str, + target_site: &str, + profile_id: &str, + disposition: &str, + terminal: &str, +) -> String { + format!( + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=send; Disposition={disposition}; Terminal={terminal}; MessageId={message_id}; LinkId={link_id}; OriginSite={origin_site}; TargetSite={target_site}; ProfileId={profile_id}" + ) +} + +#[test] +fn hierarchy_serialized_candidate_host_provenance_is_bounded() { + let mut manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let oversized_handle = format!("safe:server:{}", "a".repeat(129)); + manifest["topology"]["originHostHandle"] = serde_json::json!(oversized_handle); + manifest["artifacts"][1]["producerHostHandle"] = serde_json::json!(oversized_handle); + + let candidates = hierarchy_candidate_groups("healthy-link", &manifest) + .expect("oversized safe-looking provenance fails closed deterministically"); + assert!( + candidates + .iter() + .flat_map(|group| &group.facts) + .all(|fact| fact.artifact_id != "healthy-02-sender"), + "an oversized producer host handle reached serialized candidate facts" + ); +} + +#[test] +fn hierarchy_candidate_relative_paths_have_total_and_segment_bounds() { + let manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let mutations = [ + ( + "oversized segment", + format!("evidence/{}/sender.log", "a".repeat(129)), + ), + ( + "oversized total path", + format!("evidence/{}sender.log", "segment/".repeat(80)), + ), + ]; + let mut accepted = Vec::new(); + + for (label, relative_path) in mutations { + let mut mutated = manifest.clone(); + mutated["artifacts"][1]["relativePath"] = serde_json::json!(relative_path); + if artifact_is_exact_candidate(&mutated, &mutated["artifacts"][1]) { + accepted.push(label); + } + } + + assert!( + accepted.is_empty(), + "unbounded relative paths entered exact candidate admission: {accepted:?}" + ); +} + +#[test] +fn hierarchy_exact_profile_keys_use_closed_shared_lexical_rules() { + let valid = exact_sender_projection( + "msg-healthy-01", + "link-lab-chd", + "LAB", + "CHD", + EXACT_PROFILE, + "succeeded", + "false", + ); + assert!(parse_fixture_fields(&valid).is_ok(), "control must parse"); + + let oversized_message = "m".repeat(129); + let oversized_link = "l".repeat(129); + let invalid = [ + exact_sender_projection( + &oversized_message, + "link-lab-chd", + "LAB", + "CHD", + EXACT_PROFILE, + "succeeded", + "false", + ), + exact_sender_projection( + "msg-healthy-01", + &oversized_link, + "LAB", + "CHD", + EXACT_PROFILE, + "succeeded", + "false", + ), + exact_sender_projection( + "msg-healthy-01", + "link-lab-chd", + "LABX", + "CHD", + EXACT_PROFILE, + "succeeded", + "false", + ), + exact_sender_projection( + "msg-healthy-01", + "link-lab-chd", + "LAB", + "CHD", + "hierarchy-server-5.00.test-v2", + "succeeded", + "false", + ), + ]; + + for message in invalid { + assert!( + parse_fixture_fields(&message).is_err(), + "an out-of-profile exact key grammar was accepted" + ); + } +} + +#[test] +fn hierarchy_disposition_and_terminal_grammar_is_closed_before_candidate_creation() { + for (disposition, terminal) in [ + ("succeeded", "false"), + ("succeeded", "true"), + ("failed", "true"), + ("retrying", "false"), + ] { + let message = exact_sender_projection( + "msg-healthy-01", + "link-lab-chd", + "LAB", + "CHD", + EXACT_PROFILE, + disposition, + terminal, + ); + assert!( + parse_fixture_fields(&message).is_ok(), + "declared disposition/terminal pair must parse: {disposition}/{terminal}" + ); + } + + let oversized_disposition = "a".repeat(129); + for (disposition, terminal) in [ + (oversized_disposition.as_str(), "false"), + ("notADeclaredReplicationDisposition", "false"), + ("failed", "false"), + ("retrying", "true"), + ("succeeded", "truthy"), + ] { + let message = exact_sender_projection( + "msg-healthy-01", + "link-lab-chd", + "LAB", + "CHD", + EXACT_PROFILE, + disposition, + terminal, + ); + assert!( + parse_fixture_fields(&message).is_err(), + "undeclared disposition/terminal grammar was accepted: {disposition}/{terminal}" + ); + } +} + +#[test] +fn hierarchy_nonterminal_transactions_cannot_advertise_high_confidence_ceiling() { + let manifest = read_json("backlog-retry", "manifest.json").expect("backlog manifest loads"); + let mut expected = read_json("backlog-retry", "expected.json").expect("backlog expected loads"); + expected["transactions"][0]["confidenceCeiling"] = serde_json::json!("high"); + + let failures = identity_and_schema_failures("backlog-retry", &manifest, &expected); + assert!( + !failures.is_empty(), + "a nonterminal retry advertised a high confidence ceiling" + ); +} + +#[test] +fn hierarchy_candidate_facts_preserve_shared_timestamp_provenance_shape() { + let manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + let groups = hierarchy_candidate_groups("healthy-link", &manifest) + .expect("healthy candidate projection succeeds"); + let serialized = serde_json::to_value(&groups).expect("candidate groups serialize"); + let sender_fact = serialized + .as_array() + .into_iter() + .flatten() + .flat_map(|group| group["facts"].as_array().into_iter().flatten()) + .find(|fact| fact["artifactId"] == "healthy-02-sender") + .expect("sender candidate fact exists"); + let records = normalized_records("healthy-link", &manifest); + let sender_record = records + .get(&("healthy-02-sender".to_owned(), 1, 1)) + .expect("sender logical record exists"); + + assert_eq!( + sender_fact["timestamp"], + serde_json::to_value(&sender_record.timestamp).expect("shared timestamp serializes"), + "candidate facts must retain the shared timestamp provenance without reshaping it" + ); + assert_eq!( + sender_fact["timestamp"] + .as_object() + .expect("timestamp is an object") + .keys() + .map(String::as_str) + .collect::>(), + BTreeSet::from([ + "offsetMinutes", + "orderingState", + "originalDisplay", + "utcMillis", + ]), + "candidate timestamps must expose the exact shared provenance fields" + ); +} + +#[test] +fn hierarchy_identity_bearing_safe_looking_values_are_domain_separated_before_serialization() { + let mut manifest = read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + manifest["topology"]["originHostHandle"] = serde_json::json!("safe:server:RealUser"); + for artifact in manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .filter(|artifact| artifact["direction"] == "origin") + { + artifact["producerHostHandle"] = serde_json::json!("safe:server:RealUser"); + } + manifest["artifacts"][1]["pathFingerprint"] = serde_json::json!("synthetic:RealUser"); + + let groups = hierarchy_candidate_groups("healthy-link", &manifest) + .expect("identity-bearing safe-looking values fail closed deterministically"); + let sender = groups + .iter() + .flat_map(|group| &group.facts) + .find(|fact| fact.artifact_id == "healthy-02-sender") + .expect("mutated sender still produces a safely tokenized candidate"); + let host_digest = sender + .producer_host_handle + .strip_prefix("sccm-provenance:v1:producer-host:sha256:") + .expect("producer host uses its versioned domain"); + let path_digest = sender + .path_fingerprint + .strip_prefix("sccm-provenance:v1:path-fingerprint:sha256:") + .expect("path fingerprint uses its versioned domain"); + + for digest in [host_digest, path_digest] { + assert_eq!(digest.len(), 64, "SHA-256 token has a fixed width"); + assert!( + digest + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')), + "SHA-256 token is lowercase hexadecimal" + ); + } + assert_ne!( + sender.producer_host_handle, sender.path_fingerprint, + "equal-looking inputs must remain separated by provenance domain" + ); + assert_ne!( + candidate_provenance_token(CandidateProvenanceDomain::ProducerHost, "same-input"), + candidate_provenance_token(CandidateProvenanceDomain::PathFingerprint, "same-input"), + "identical input bytes must hash differently in distinct provenance domains" + ); + let serialized = serde_json::to_string(&groups).expect("candidate groups serialize"); + assert!( + !serialized.contains("RealUser"), + "identity-bearing input reached serialized candidate output" + ); +} + +#[test] +fn hierarchy_equal_utc_requires_same_artifact_and_forward_physical_lines() { + let expected = read_json("healthy-link", "expected.json").expect("healthy expected loads"); + let mut cross_artifact = + read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + cross_artifact["artifacts"][2]["relativePath"] = + serde_json::json!("evidence/server-hierarchy-transfer/target/equal-instant/despool.log"); + + let cross_artifact_failures = + identity_and_schema_failures("healthy-link", &cross_artifact, &expected); + assert!( + cross_artifact_failures + .iter() + .any(|failure| failure.contains("equal UTC across distinct artifacts")), + "equal UTC on sender/despool artifacts retained a usable/high ordering ceiling: {cross_artifact_failures:?}" + ); + + let mut same_artifact = + read_json("healthy-link", "manifest.json").expect("healthy manifest loads"); + same_artifact["artifacts"][0]["relativePath"] = + serde_json::json!("evidence/server-hierarchy-control/origin/equal-instant/replmgr.log"); + let same_artifact_failures = + identity_and_schema_failures("healthy-link", &same_artifact, &expected); + assert!( + same_artifact_failures.is_empty(), + "equal UTC on forward physical lines of one artifact must remain usable: {same_artifact_failures:?}" + ); +} + +#[test] +fn hierarchy_readme_distinguishes_metadata_from_raw_ccm_evidence() { + let readme = include_str!("fixtures/sccm/server/hierarchy_and_replication/README.md"); + + assert!( + readme.contains("Only these raw CCM files are used as evidence:"), + "README must distinguish raw CCM evidence from manifest/expected metadata" + ); + assert!(readme.contains("`manifest.json` records additive SCCM artifact coverage")); + assert!(readme.contains("`expected.json` records the proposed #331 evidence")); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs new file mode 100644 index 000000000..1d3fd55f7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -0,0 +1,3073 @@ +use cmtraceopen_parser::models::log_entry::Severity; +use cmtraceopen_parser::sccm::server::windows::{ + assess_server_intake, declared_server_source_catalog, SccmServerArtifactPayload, + SccmServerIntakeError, SccmServerSourceKind, +}; +use cmtraceopen_parser::sccm::{ + SccmConfidence, SccmCoverageState, SccmFinding, SccmFindingBuilder, SccmFindingClass, + SccmFindingCoverageGap, SccmPhase, SccmRole, SccmRotation, +}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +fn intake_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/server/intake") +} + +fn load_bundle(scenario: &str) -> (String, Vec) { + let scenario_root = intake_root().join(scenario); + let manifest_json = + std::fs::read_to_string(scenario_root.join("manifest.json")).expect("manifest is readable"); + let manifest: Value = serde_json::from_str(&manifest_json).expect("manifest is valid JSON"); + let payloads = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"] + .as_str() + .expect("artifactId is a string") + .to_owned(), + bytes: std::fs::read(scenario_root.join(relative_path)) + .expect("captured artifact bytes are readable"), + }) + }) + .collect(); + (manifest_json, payloads) +} + +fn manifest_value(manifest_json: &str) -> Value { + serde_json::from_str(manifest_json).expect("manifest is valid JSON") +} + +fn serialize_manifest(manifest: &Value) -> String { + serde_json::to_string(manifest).expect("manifest serializes") +} + +fn manifest_scope_needle(scope: &str) -> &'static str { + match scope { + "manifest" => "{", + "privacy" => "\"privacy\":{", + "topology" => "\"topology\":{", + "artifact" => "\"artifacts\":[{", + "workflowSubject" => "\"workflowSubject\":{", + "configuredPathProvenance" => "\"configuredPathProvenance\":{", + "rotation" => "\"rotation\":{", + "collectionLimit" => "\"collectionLimit\":{", + _ => panic!("unknown extension scope: {scope}"), + } +} + +fn manifest_with_duplicate_extension( + manifest_json: &str, + scope: &str, + extension_name: &str, + first_value: &str, + second_value: &str, +) -> String { + let needle = manifest_scope_needle(scope); + let prefix = format!( + "{needle}\"{extension_name}\":\"{first_value}\",\"{extension_name}\":\"{second_value}\"," + ); + let mutated = manifest_json.replacen(needle, &prefix, 1); + assert_ne!(mutated, manifest_json, "scope marker must be present"); + mutated +} + +fn manifest_with_duplicate_known_field( + manifest_json: &str, + scope: &str, + field_name: &str, + field_value: &Value, +) -> String { + let needle = manifest_scope_needle(scope); + let field_value = serde_json::to_string(field_value).expect("duplicate field value serializes"); + let prefix = format!("{needle}\"{field_name}\":{field_value},"); + let mutated = manifest_json.replacen(needle, &prefix, 1); + assert_ne!(mutated, manifest_json, "scope marker must be present"); + mutated +} + +fn manifest_with_ordered_extensions( + manifest_json: &str, + scopes: &[&str], + extensions: &[(&str, &str)], +) -> String { + let fields = extensions + .iter() + .map(|(name, value)| { + format!( + "{}:{},", + serde_json::to_string(name).expect("extension name serializes"), + serde_json::to_string(value).expect("extension value serializes") + ) + }) + .collect::(); + let mut mutated = manifest_json.to_owned(); + for scope in scopes { + let needle = manifest_scope_needle(scope); + let replacement = format!("{needle}{fields}"); + let next = mutated.replacen(needle, &replacement, 1); + assert_ne!(next, mutated, "scope marker must be present: {scope}"); + mutated = next; + } + mutated +} + +fn load_expected(scenario: &str) -> Value { + let path = intake_root().join(scenario).join("expected.json"); + let json = std::fs::read_to_string(path).expect("expected intake output is readable"); + serde_json::from_str(&json).expect("expected intake output is valid JSON") +} + +fn intake_scenarios() -> Vec { + let mut scenarios = std::fs::read_dir(intake_root()) + .expect("intake fixture root is readable") + .filter_map(Result::ok) + .filter(|entry| entry.path().join("expected.json").is_file()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(); + scenarios.sort(); + assert!( + !scenarios.is_empty(), + "the intake fixture root must contain committed expected.json oracles" + ); + scenarios +} + +fn opaque_handle(prefix: &str, ordinal: usize) -> String { + format!("{prefix}{ordinal:064x}") +} + +fn bounded_manifest( + artifact_count: usize, + byte_limit: u64, +) -> (String, Vec) { + let capture_host = opaque_handle("cmtraceopen.host.sha256.v1:", 1); + let site_code = opaque_handle("cmtraceopen.site.sha256.v1:", 1); + let producer_host = opaque_handle("cmtraceopen.host.sha256.v1:", 2); + let mut artifacts = Vec::with_capacity(artifact_count); + let mut payloads = Vec::with_capacity(artifact_count); + + for ordinal in 0..artifact_count { + let artifact_id = opaque_handle("cmtraceopen.artifact.sha256.v1:", ordinal); + artifacts.push(json!({ + "artifactId": artifact_id.clone(), + "producerRole": "managementPoint", + "producerHostHandle": producer_host.clone(), + "sourceId": "server-mp-policy", + "sourceKind": "ccmLog", + "sourceVersion": "5.00.9999.9999", + "originalPath": "REDACTED", + "originalBasename": "MP_GetPolicy.log", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": opaque_handle("cmtraceopen.path.sha256.v1:", ordinal), + }, + "rotation": { + "kind": "current", + "lineageId": opaque_handle("cmtraceopen.lineage.sha256.v1:", ordinal), + }, + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": { "byteLimit": byte_limit, "limitApplied": false }, + "collectedUtc": "2026-07-30T00:03:00Z", + "relativePath": format!( + "evidence/sccm/server/management-point/server-mp-policy/root-{ordinal:08x}/current/MP_GetPolicy.log" + ), + "bytesCopied": 0, + })); + payloads.push(SccmServerArtifactPayload { + manifest_artifact_id: artifact_id, + bytes: Vec::new(), + }); + } + + ( + serde_json::to_string(&json!({ + "sccmManifestVersion": 1, + "syntheticFixture": false, + "bundleRole": "server", + "topology": { + "captureHost": capture_host, + "siteCode": site_code, + "rolesObserved": ["managementPoint"], + }, + "artifacts": artifacts, + })) + .expect("bounded manifest serializes"), + payloads, + ) +} + +fn opaque_future_role_manifest( + capture_state: &str, + ordinal: usize, +) -> ( + String, + Vec, + String, + Option, +) { + let (manifest_json, _) = bounded_manifest(1, 4_096); + let mut manifest = manifest_value(&manifest_json); + let role_digest = format!("{ordinal:064x}"); + let source_digest = format!("{:064x}", ordinal + 1); + let basename_digest = format!("{:064x}", ordinal + 2); + let future_role = format!("cmtraceopen.role.sha256.v1:{role_digest}"); + let source_id = format!("cmtraceopen.source.sha256.v1:{source_digest}"); + let source_kind = opaque_handle("cmtraceopen.source-kind.sha256.v1:", ordinal + 3); + let original_basename = format!("cmtraceopen.basename.sha256.v1:{basename_digest}"); + manifest["topology"]["rolesObserved"] = json!(["managementPoint", future_role]); + + let artifact = &mut manifest["artifacts"][0]; + let artifact_id = artifact["artifactId"] + .as_str() + .expect("bounded artifact ID is a string") + .to_owned(); + artifact["producerRole"] = Value::String(future_role.clone()); + artifact["producerHostHandle"] = + Value::String(opaque_handle("cmtraceopen.host.sha256.v1:", ordinal + 4)); + artifact["sourceId"] = Value::String(source_id); + artifact["sourceKind"] = Value::String(source_kind); + artifact["sourceVersion"] = Value::Null; + artifact["originalPath"] = Value::String(opaque_handle( + "cmtraceopen.original-path.sha256.v1:", + ordinal + 5, + )); + artifact["originalBasename"] = Value::String(original_basename); + artifact["configuredPathProvenance"] = json!({ + "state": "supplied", + "pathFingerprint": opaque_handle("cmtraceopen.path.sha256.v1:", ordinal + 6), + }); + artifact["captureState"] = Value::String(capture_state.to_owned()); + artifact["collectionDetail"] = Value::Null; + artifact["skipReason"] = Value::Null; + artifact["unsupportedReason"] = Value::Null; + artifact["truncated"] = Value::Null; + artifact["fragmentComplete"] = Value::Null; + + let (payloads, relative_path) = if capture_state == "capped" { + let bytes = vec![b'x'; 16]; + let relative_path = format!( + "evidence/sccm/server/role-{role_digest}/source-{source_digest}/current/basename-{basename_digest}" + ); + artifact["rotation"] = json!({ + "kind": "current", + "lineageId": opaque_handle("cmtraceopen.lineage.sha256.v1:", ordinal + 7), + }); + artifact["encoding"] = Value::String("utf-8".to_owned()); + artifact["collectionLimit"] = json!({ "byteLimit": 16, "limitApplied": true }); + artifact["bytesCopied"] = Value::from(16); + artifact["truncated"] = Value::Bool(true); + artifact["fragmentComplete"] = Value::Bool(false); + artifact["relativePath"] = Value::String(relative_path.clone()); + ( + vec![SccmServerArtifactPayload { + manifest_artifact_id: artifact_id, + bytes, + }], + Some(relative_path), + ) + } else { + artifact["rotation"] = json!({ + "kind": "none", + "lineageId": opaque_handle("cmtraceopen.lineage.sha256.v1:", ordinal + 7), + }); + artifact["encoding"] = Value::Null; + artifact["collectionLimit"] = Value::Null; + artifact["bytesCopied"] = Value::from(0); + artifact["relativePath"] = Value::Null; + if capture_state == "accessDenied" { + artifact["collectionDetail"] = Value::String(opaque_handle( + "cmtraceopen.collection-detail.sha256.v1:", + ordinal + 8, + )); + } else if capture_state == "unsupported" { + artifact["unsupportedReason"] = Value::String(opaque_handle( + "cmtraceopen.unsupported-reason.sha256.v1:", + ordinal + 8, + )); + } + (Vec::new(), None) + }; + + ( + serialize_manifest(&manifest), + payloads, + future_role, + relative_path, + ) +} + +fn assert_unsafe_mutation_is_rejected( + scenario: &str, + marker: &str, + mutate: impl FnOnce(&mut Value, &mut Vec), +) { + let (manifest_json, mut payloads) = load_bundle(scenario); + let mut manifest = manifest_value(&manifest_json); + mutate(&mut manifest, &mut payloads); + + match assess_server_intake(&serialize_manifest(&manifest), &payloads) { + Err(_) => {} + Ok(assessment) => { + let serialized = serde_json::to_string(&assessment).expect("assessment serializes"); + assert!( + !serialized + .to_ascii_lowercase() + .contains(&marker.to_ascii_lowercase()), + "unsafe marker was projected into public JSON: {serialized}" + ); + panic!("unsafe manifest mutation was accepted"); + } + } +} + +fn artifact_json<'a>(assessment: &'a Value, artifact_id: &str) -> &'a Value { + assessment["artifacts"] + .as_array() + .expect("assessment artifacts are an array") + .iter() + .find(|artifact| artifact["artifactId"] == artifact_id) + .expect("artifact is present") +} + +fn reversed_assessment_json(manifest_json: &str, payloads: &[SccmServerArtifactPayload]) -> Value { + let mut manifest = manifest_value(manifest_json); + manifest["artifacts"] + .as_array_mut() + .expect("manifest artifacts are an array") + .reverse(); + let mut reversed_payloads = payloads.to_vec(); + reversed_payloads.reverse(); + let assessment = assess_server_intake(&serialize_manifest(&manifest), &reversed_payloads) + .expect("reordered manifest remains assessable"); + serde_json::to_value(assessment).expect("reordered assessment serializes") +} + +fn assert_unique_public_relative_paths(scenario: &str, actual: &Value) { + let paths = actual["artifacts"] + .as_array() + .expect("assessment artifacts are an array") + .iter() + .filter_map(|artifact| artifact["relativePath"].as_str()) + .collect::>(); + assert_eq!( + paths.iter().copied().collect::>().len(), + paths.len(), + "{scenario}: relative paths are collision-safe" + ); +} + +fn assert_collision_contract(scenario: &str, expected: &Value, actual: &Value) { + let artifacts = actual["artifacts"] + .as_array() + .expect("assessment artifacts are an array"); + for (key, value) in expected + .as_object() + .expect("collision assertions are an object") + { + match key.as_str() { + "sameBasename" => { + let expected_basename = value + .as_str() + .expect("sameBasename expectation is a string"); + let actual_basenames = artifacts + .iter() + .map(|artifact| artifact["originalBasename"].as_str()) + .collect::>(); + assert!( + actual_basenames + .iter() + .all(|basename| *basename == Some(expected_basename)), + "{scenario}: expected every basename to be {expected_basename:?}, got {actual_basenames:?}" + ); + } + "distinctPathFingerprints" => { + let fingerprints = artifacts + .iter() + .map(|artifact| artifact["pathFingerprint"].as_str().expect("fingerprint")) + .collect::>(); + assert_eq!(fingerprints.len(), artifacts.len(), "{scenario}: {key}"); + assert_eq!(value, true, "{scenario}: {key} expectation"); + } + "distinctOpaqueRootSegments" => { + assert_eq!(value, true, "{scenario}: {key} expectation"); + let root_segments = artifacts + .iter() + .map(|artifact| { + artifact["relativePath"] + .as_str() + .expect("relative path") + .split('/') + .find(|segment| segment.starts_with("root-")) + .expect("opaque configured-root segment") + }) + .collect::>(); + assert_eq!(root_segments.len(), artifacts.len(), "{scenario}: {key}"); + } + "notMerged" => { + assert_eq!(value, true, "{scenario}: {key} expectation"); + let content_digests = artifacts + .iter() + .map(|artifact| artifact["contentSha256"].as_str().expect("content digest")) + .collect::>(); + assert_eq!(content_digests.len(), artifacts.len(), "{scenario}: {key}"); + } + "exactReferencesResolve" => { + assert_eq!(value, true, "{scenario}: {key} expectation"); + let artifact_ids = artifacts + .iter() + .map(|artifact| artifact["artifactId"].as_str().expect("artifact ID")) + .collect::>(); + for coverage in actual["coverage"].as_array().expect("coverage is an array") { + for artifact_id in coverage["artifactIds"] + .as_array() + .expect("coverage artifact IDs are an array") + { + assert!(artifact_ids.contains(artifact_id.as_str().expect("artifact ID"))); + } + } + for evidence in actual["evidence"].as_array().expect("evidence is an array") { + assert!(artifact_ids.contains( + evidence["reference"]["artifactId"] + .as_str() + .expect("evidence artifact ID") + )); + } + } + "normalizedArtifactCount" => assert_eq!( + artifacts.len() as u64, + value.as_u64().expect("artifact count is an integer"), + "{scenario}: {key}" + ), + other => panic!("{scenario}: unhandled collision assertion {other}"), + } + } +} + +fn assert_remaining_expected_contracts( + scenario: &str, + expected: &Value, + manifest_json: &str, + payloads: &[SccmServerArtifactPayload], + actual: &Value, +) { + let manifest = manifest_value(manifest_json); + let actual_artifacts = actual["artifacts"] + .as_array() + .expect("assessment artifacts are an array"); + for (key, value) in expected + .as_object() + .expect("expected contract is an object") + { + match key.as_str() { + "pre318ExpectedVersion" + | "artifactId" + | "artifactProvenance" + | "canonicalArtifactIds" + | "canonicalRotationArtifactIds" + | "configuredPathProvenance" + | "coverage" + | "evidence" + | "retainedUnclassifiedArtifactIds" + | "rolesObserved" => {} + "nonCapturedProvenance" => { + assert_eq!(value["encoding"], "omitted", "{scenario}: encoding"); + assert_eq!( + value["collectionLimit"], "omitted", + "{scenario}: collection limit" + ); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .filter(|artifact| artifact["relativePath"].is_null()) + { + assert!(artifact.get("encoding").is_none_or(Value::is_null)); + assert!(artifact.get("collectionLimit").is_none_or(Value::is_null)); + } + assert!(actual_artifacts + .iter() + .filter(|artifact| artifact["relativePath"].is_null()) + .all(|artifact| artifact["captureProvenance"].is_null())); + } + "nextArtifactRequest" => { + let requests = actual["nextArtifactRequests"] + .as_array() + .expect("requests are an array"); + assert_eq!(requests.len(), 1, "{scenario}: one bounded request"); + match value.as_str().expect("request expectation is a string") { + "read-only capture of server-mp-policy from the observed management point" => { + assert_eq!(requests[0]["logicalId"], "mpGetPolicy"); + assert_eq!(requests[0]["role"], "managementPoint"); + } + "bounded recapture of server-sup-sync with a cap sufficient for complete logical records" => { + assert_eq!(requests[0]["logicalId"], "wsyncmgr"); + assert_eq!(requests[0]["role"], "siteServer"); + } + other => panic!("{scenario}: unhandled request contract {other}"), + } + } + "terminalManagementPointDiagnosis" + | "terminalSoftwareUpdatePointHealth" + | "partialPhysicalFragmentCreatesTerminalResult" + | "requiredSourceFailure" => { + assert_eq!(value, false, "{scenario}: {key} expectation"); + assert!(actual["findings"] + .as_array() + .expect("findings are an array") + .is_empty()); + } + "roleHealthFinding" | "databaseOrRoleFinding" => { + assert_eq!(value, "none", "{scenario}: {key} expectation"); + assert!(actual["findings"] + .as_array() + .expect("findings are an array") + .is_empty()); + } + "forbiddenConclusion" => { + let serialized = serde_json::to_string(actual).expect("assessment serializes"); + assert!(!serialized + .to_ascii_lowercase() + .contains(&value.as_str().expect("forbidden text").to_ascii_lowercase())); + } + "privacy" => { + assert_eq!(value, "synthetic", "{scenario}: privacy declaration"); + assert_eq!(manifest["syntheticFixture"], true); + assert_eq!(manifest["privacy"]["synthetic"], true); + assert_eq!(manifest["privacy"]["rawPaths"], "redacted"); + let serialized = serde_json::to_string(actual).expect("assessment serializes"); + assert!(!serialized.contains("REDACTED")); + } + "defaultCandidateInterpretation" => { + assert_eq!(value, "candidateAbsentOnly"); + assert_eq!( + manifest["artifacts"][0]["defaultCandidateState"], + "absentCandidateOnly" + ); + } + "roleInference" => { + assert_eq!( + value, + "managementPoint is observed from topology, not from default path" + ); + assert!(actual["topology"]["rolesObserved"] + .as_array() + .expect("roles are an array") + .contains(&Value::String("managementPoint".to_owned()))); + } + "lineageId" => assert!(actual_artifacts + .iter() + .all(|artifact| { artifact["rotationLineageHandle"] == *value })), + "totalRotationSort" => { + assert_eq!(value, true); + let ids = actual_artifacts + .iter() + .map(|artifact| artifact["artifactId"].clone()) + .collect::(); + assert_eq!(ids, expected["canonicalRotationArtifactIds"]); + } + "serializationOrderIsChronology" => { + assert_eq!(value, false); + let instants = actual_artifacts + .iter() + .map(|artifact| artifact["collectedAtUtc"].as_str().expect("timestamp")) + .collect::>(); + assert_eq!( + instants.len(), + 1, + "{scenario}: chronology cannot choose order" + ); + } + "uniqueRelativePaths" | "collisionSafe" => { + assert_eq!(value, true, "{scenario}: {key} expectation"); + assert_unique_public_relative_paths(scenario, actual); + } + "collisionAssertions" => assert_collision_contract(scenario, value, actual), + "normalizedOutputByteIdenticalWhenReordered" + | "artifactIdDerivationIgnoresDiscoveryOrder" => { + assert_eq!(value, true, "{scenario}: {key} expectation"); + assert_eq!( + reversed_assessment_json(manifest_json, payloads), + *actual, + "{scenario}: {key}" + ); + } + "artifactIdUniquenessScope" => { + assert_eq!(value, "manifest"); + let ids = actual_artifacts + .iter() + .map(|artifact| artifact["artifactId"].as_str().expect("artifact ID")) + .collect::>(); + assert_eq!(ids.len(), actual_artifacts.len()); + } + "crossBundleArtifactIdReuseAllowed" => { + assert_eq!(value, true); + let mut second_manifest = manifest_value(manifest_json); + second_manifest["topology"]["captureHost"] = + if second_manifest["syntheticFixture"] == true { + let current_host = second_manifest["topology"]["captureHost"] + .as_str() + .expect("synthetic capture host is a string"); + Value::String( + if current_host == "LAB-MP01" { + "LAB-CM01" + } else { + "LAB-MP01" + } + .to_owned(), + ) + } else { + Value::String(opaque_handle("cmtraceopen.host.sha256.v1:", 999)) + }; + let repeated = + assess_server_intake(&serialize_manifest(&second_manifest), payloads) + .expect("a distinct bundle may reuse manifest-scoped IDs"); + let repeated = serde_json::to_value(repeated).expect("repeat serializes"); + assert_eq!( + repeated["artifacts"] + .as_array() + .expect("repeat artifacts are an array") + .iter() + .map(|artifact| artifact["artifactId"].clone()) + .collect::>(), + actual_artifacts + .iter() + .map(|artifact| artifact["artifactId"].clone()) + .collect::>(), + "{scenario}: distinct bundles may reuse manifest-scoped IDs" + ); + assert_ne!(repeated["topology"], actual["topology"]); + } + "deterministicEvidenceIds" => { + assert_eq!(value, true); + assert_eq!( + reversed_assessment_json(manifest_json, payloads)["evidence"], + actual["evidence"] + ); + } + "rawByteCountedBeforeDecoding" => { + assert_eq!(value, true); + for payload in payloads { + let artifact = artifact_json(actual, &payload.manifest_artifact_id); + assert_eq!(artifact["bytesCopied"], payload.bytes.len() as u64); + } + } + "completeCcmRecordCount" => assert_eq!( + actual["evidence"] + .as_array() + .expect("evidence is an array") + .len() as u64, + value.as_u64().expect("record count is an integer") + ), + "logicalRecordParseable" => { + assert_eq!(value, false); + assert!(actual["evidence"] + .as_array() + .expect("evidence is an array") + .is_empty()); + } + "eligibleForRoleReducer" => { + assert_eq!(value, false); + assert!(actual_artifacts + .iter() + .all(|artifact| artifact["parserEligible"] == false)); + } + other => panic!("{scenario}: unhandled expected contract key {other}"), + } + } +} + +fn assert_request_passes_finding_boundaries( + scenario: &str, + assessment: &cmtraceopen_parser::sccm::server::windows::SccmServerIntakeAssessment, +) { + let request = assessment + .next_artifact_requests + .first() + .unwrap_or_else(|| panic!("{scenario} emits one bounded request")); + let artifact = assessment + .artifacts + .first() + .unwrap_or_else(|| panic!("{scenario} retains its coverage artifact")); + let finding = SccmFindingBuilder::new(format!("server-intake-{scenario}")) + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Unknown("serverIntake".to_owned())) + .role(request.role.clone()) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(SccmFindingCoverageGap { + artifact_id: artifact.artifact_id.clone(), + role: request.role.clone(), + coverage: artifact.state.clone(), + }) + .next_artifact(request.clone()) + .build() + .unwrap_or_else(|error| panic!("{scenario} request must validate: {error:?}")); + + let serialized = serde_json::to_value(&finding) + .unwrap_or_else(|error| panic!("{scenario} finding must serialize: {error}")); + let deserialized = serde_json::from_value::(serialized) + .unwrap_or_else(|error| panic!("{scenario} finding must deserialize: {error}")); + assert_eq!( + deserialized, finding, + "{scenario} request and coverage data must survive the JSON boundary" + ); +} + +#[test] +fn server_intake_normalizes_role_coverage_and_logical_records() { + let (complete_manifest, complete_payloads) = load_bundle("complete-multi-role"); + let complete = + assess_server_intake(&complete_manifest, &complete_payloads).expect("bundle is assessed"); + + assert_eq!(complete.schema_version, 1); + assert_eq!( + complete + .coverage + .iter() + .map(|row| ( + row.producer_role.clone(), + row.workflow_subject_role.clone(), + row.source_id.as_str(), + row.state.clone(), + )) + .collect::>(), + vec![ + ( + SccmRole::ManagementPoint, + None, + "server-mp-policy", + SccmCoverageState::Captured, + ), + ( + SccmRole::SiteServer, + Some(SccmRole::DistributionPoint), + "server-dp-distribution", + SccmCoverageState::Captured, + ), + ( + SccmRole::SiteServer, + None, + "server-sitecomp", + SccmCoverageState::Captured, + ), + ( + SccmRole::SiteServer, + Some(SccmRole::SoftwareUpdatePoint), + "server-sup-sync", + SccmCoverageState::Captured, + ), + ] + ); + assert_eq!(complete.evidence.len(), 4); + assert!(complete.findings.is_empty()); + + let (multiline_manifest, multiline_payloads) = load_bundle("multiline"); + let multiline = + assess_server_intake(&multiline_manifest, &multiline_payloads).expect("bundle is assessed"); + assert_eq!(multiline.evidence.len(), 1); + assert_eq!(multiline.evidence[0].reference.line_start, Some(1)); + assert_eq!(multiline.evidence[0].reference.line_end, Some(2)); + + let (absent_manifest, absent_payloads) = load_bundle("absent-dp"); + let absent = + assess_server_intake(&absent_manifest, &absent_payloads).expect("bundle is assessed"); + assert_eq!(absent.coverage.len(), 1); + assert_eq!(absent.coverage[0].state, SccmCoverageState::Absent); + assert!(absent.evidence.is_empty()); + assert!(absent.findings.is_empty()); + assert_eq!(absent.next_artifact_requests.len(), 1); + assert_eq!(absent.next_artifact_requests[0].logical_id, "distmgr"); + + let (unsorted_manifest, unsorted_payloads) = load_bundle("unsorted-manifest"); + let unsorted = + assess_server_intake(&unsorted_manifest, &unsorted_payloads).expect("bundle is assessed"); + let mut reordered_manifest: Value = + serde_json::from_str(&unsorted_manifest).expect("manifest is valid JSON"); + reordered_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .reverse(); + let reordered = assess_server_intake( + &serde_json::to_string(&reordered_manifest).expect("manifest serializes"), + &unsorted_payloads, + ) + .expect("reordered bundle is assessed"); + assert_eq!( + serde_json::to_vec(&unsorted).expect("assessment serializes"), + serde_json::to_vec(&reordered).expect("assessment serializes"), + "manifest order must not affect normalized output" + ); +} + +#[test] +fn server_intake_gap_requests_use_exact_shared_catalog_artifacts() { + let cases = [ + ( + "absent-dp", + "distmgr", + SccmRole::SiteServer, + "Collect the complete distmgr.log file.", + ), + ( + "access-denied-mp", + "mpGetPolicy", + SccmRole::ManagementPoint, + "Collect the complete MP_GetPolicy.log file.", + ), + ( + "capped-sup", + "wsyncmgr", + SccmRole::SiteServer, + "Collect the complete wsyncmgr.log file.", + ), + ]; + + for (scenario, logical_id, role, reason) in cases { + let (manifest, payloads) = load_bundle(scenario); + let assessment = assess_server_intake(&manifest, &payloads) + .unwrap_or_else(|error| panic!("{scenario} should be assessed: {error}")); + + assert_request_passes_finding_boundaries(scenario, &assessment); + assert_eq!(assessment.next_artifact_requests.len(), 1, "{scenario}"); + let request = &assessment.next_artifact_requests[0]; + assert_eq!(request.logical_id, logical_id, "{scenario}"); + assert_eq!(request.role, role, "{scenario}"); + assert_eq!(request.reason, reason, "{scenario}"); + } +} + +#[test] +fn server_intake_does_not_request_unknown_or_non_ccm_sources() { + let (iis_manifest, iis_payloads) = load_bundle("skipped-iis"); + let mut denied_iis = manifest_value(&iis_manifest); + denied_iis["artifacts"][0]["captureState"] = Value::String("accessDenied".to_owned()); + denied_iis["artifacts"][0]["skipReason"] = Value::Null; + let iis = assess_server_intake(&serialize_manifest(&denied_iis), &iis_payloads) + .expect("non-CCM coverage remains assessable"); + assert!( + iis.next_artifact_requests.is_empty(), + "a non-CCM group has no shared catalog artifact request" + ); + + let (unknown_manifest, unknown_payloads) = load_bundle("unsupported-db-supplement"); + let unknown = assess_server_intake(&unknown_manifest, &unknown_payloads) + .expect("unknown coverage remains assessable"); + assert!( + unknown.next_artifact_requests.is_empty(), + "an unknown source has no shared catalog artifact request" + ); +} + +#[test] +fn server_intake_rejects_identity_bearing_public_inputs() { + assert_unsafe_mutation_is_rejected("complete-multi-role", "realuser", |manifest, _payloads| { + manifest["artifacts"][0]["relativePath"] = Value::String( + "evidence/sccm/server/site-server/server-sitecomp/current/RealUsersitecomp.log" + .to_owned(), + ); + }); + assert_unsafe_mutation_is_rejected("complete-multi-role", "realuser", |manifest, _payloads| { + manifest["artifacts"][0]["relativePath"] = Value::String( + "evidence/sccm/server/site-server/realuser/current/sitecomp.log".to_owned(), + ); + }); + assert_unsafe_mutation_is_rejected( + "complete-multi-role", + "realuser.example.test", + |manifest, _payloads| { + manifest["artifacts"][0]["sourceVersion"] = + Value::String("realuser.example.test".to_owned()); + }, + ); + assert_unsafe_mutation_is_rejected("complete-multi-role", "realuser", |manifest, payloads| { + manifest["artifacts"][0]["artifactId"] = Value::String("realuser".to_owned()); + payloads[0].manifest_artifact_id = "realuser".to_owned(); + }); + assert_unsafe_mutation_is_rejected("complete-multi-role", "realuser", |manifest, _payloads| { + manifest["artifacts"][0]["producerHostHandle"] = + Value::String("synthetic:host:realuser".to_owned()); + }); + assert_unsafe_mutation_is_rejected("complete-multi-role", "realuser", |manifest, _payloads| { + manifest["artifacts"][2]["workflowSubject"]["instanceHandle"] = + Value::String("synthetic:subject:realuser".to_owned()); + }); + assert_unsafe_mutation_is_rejected("complete-multi-role", "realuser", |manifest, _payloads| { + manifest["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"] = + Value::String("synthetic:path:realuser".to_owned()); + }); + assert_unsafe_mutation_is_rejected("complete-multi-role", "realuser", |manifest, _payloads| { + manifest["artifacts"][0]["rotation"]["lineageId"] = Value::String("realuser".to_owned()); + }); + assert_unsafe_mutation_is_rejected("complete-multi-role", "realuser", |manifest, _payloads| { + manifest["topology"]["captureHost"] = Value::String("LAB-REALUSER".to_owned()); + }); +} + +#[test] +fn server_intake_reserves_windows_equivalent_paths_and_fingerprints() { + let (manifest_json, payloads) = load_bundle("collision-same-basename-configured-roots"); + let accepted = + assess_server_intake(&manifest_json, &payloads).expect("distinct roots are valid"); + assert_eq!(accepted.artifacts.len(), 2); + + let mut case_collision = manifest_value(&manifest_json); + case_collision["artifacts"][1]["relativePath"] = Value::String( + "evidence/sccm/server/management-point/server-mp-policy/root-7D4A9C2E/current/MP_GetPolicy.log" + .to_owned(), + ); + assert!( + assess_server_intake(&serialize_manifest(&case_collision), &payloads).is_err(), + "Windows-equivalent destination paths must collide" + ); + + let mut fingerprint_collision = manifest_value(&manifest_json); + fingerprint_collision["artifacts"][1]["configuredPathProvenance"]["pathFingerprint"] = + fingerprint_collision["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"] + .clone(); + assert!( + assess_server_intake(&serialize_manifest(&fingerprint_collision), &payloads).is_err(), + "two physical candidates must not share one path fingerprint" + ); + + let mut exact_collision = manifest_value(&manifest_json); + exact_collision["artifacts"][1]["relativePath"] = + exact_collision["artifacts"][0]["relativePath"].clone(); + assert!( + assess_server_intake(&serialize_manifest(&exact_collision), &payloads).is_err(), + "exact destination paths must collide" + ); +} + +#[test] +fn server_intake_rejects_mp_produced_mpcontrol_without_workflow_subject() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let mut manifest = manifest_value(&manifest_json); + let artifact = manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "mp-policy-current") + .expect("MP policy artifact is present"); + artifact["originalBasename"] = Value::String("mpcontrol.log".to_owned()); + artifact["relativePath"] = Value::String( + "evidence/sccm/server/management-point/server-mp-policy/current/mpcontrol.log".to_owned(), + ); + + assert!( + assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), + "mpcontrol is not physically produced by the Management Point role" + ); +} + +#[test] +fn server_intake_accepts_site_server_mpcontrol_with_management_point_subject() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let mut manifest = manifest_value(&manifest_json); + let artifact = manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "mp-policy-current") + .expect("MP policy artifact is present"); + artifact["producerRole"] = Value::String("siteServer".to_owned()); + artifact["producerHostHandle"] = Value::String("synthetic:host:site-01".to_owned()); + artifact["workflowSubject"] = json!({ "role": "managementPoint" }); + artifact["originalBasename"] = Value::String("mpcontrol.log".to_owned()); + artifact["relativePath"] = Value::String( + "evidence/sccm/server/site-server/server-mp-policy/subject-management-point/current/mpcontrol.log" + .to_owned(), + ); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("site-server-produced MP control evidence is assessed"); + let mpcontrol = assessment + .artifacts + .iter() + .find(|artifact| artifact.artifact_id == "mp-policy-current") + .expect("MP control artifact is retained"); + assert_eq!(mpcontrol.producer_role, SccmRole::SiteServer); + assert_eq!( + mpcontrol.workflow_subject_role, + Some(SccmRole::ManagementPoint) + ); + assert_eq!(mpcontrol.source_id, "server-mp-policy"); +} + +#[test] +fn server_intake_rejects_relabelled_duplicate_canonical_artifact_identity() { + let (manifest_json, payloads) = load_bundle("collision-same-basename-configured-roots"); + let mut manifest = manifest_value(&manifest_json); + let fingerprint = + manifest["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"].clone(); + let lineage = manifest["artifacts"][0]["rotation"]["lineageId"].clone(); + manifest["artifacts"][1]["configuredPathProvenance"]["pathFingerprint"] = fingerprint; + manifest["artifacts"][1]["rotation"]["lineageId"] = lineage; + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::DuplicateArtifact), + "caller-chosen artifact and root labels must not duplicate one canonical identity", + ); +} + +#[test] +fn server_intake_scopes_canonical_identity_to_producer_host() { + let (manifest_json, payloads) = load_bundle("collision-same-basename-configured-roots"); + let mut manifest = manifest_value(&manifest_json); + let fingerprint = + manifest["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"].clone(); + let lineage = manifest["artifacts"][0]["rotation"]["lineageId"].clone(); + manifest["artifacts"][0]["producerHostHandle"] = + Value::String("synthetic:host:site-01".to_owned()); + manifest["artifacts"][1]["producerHostHandle"] = + Value::String("synthetic:host:mp-01".to_owned()); + manifest["artifacts"][1]["configuredPathProvenance"]["pathFingerprint"] = fingerprint; + manifest["artifacts"][1]["rotation"]["lineageId"] = lineage; + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("the same artifact identity on a distinct producer host is independent"); + assert_eq!(assessment.artifacts.len(), 2); + assert_eq!( + assessment + .artifacts + .iter() + .map(|artifact| artifact.producer_host_handle.as_deref()) + .collect::>(), + vec![Some("synthetic:host:mp-01"), Some("synthetic:host:site-01"),], + "producer-host provenance orders otherwise-equal artifacts before caller ids", + ); + + let mut reordered_manifest = manifest.clone(); + reordered_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .reverse(); + let reordered = assess_server_intake(&serialize_manifest(&reordered_manifest), &payloads) + .expect("reordered distinct-host artifacts are assessed"); + assert_eq!( + serde_json::to_vec(&assessment).expect("assessment serializes"), + serde_json::to_vec(&reordered).expect("reordered assessment serializes"), + "distinct-host output is independent of manifest order", + ); +} + +#[test] +fn server_intake_coverage_binds_each_row_to_its_producer_host() { + let (manifest_json, payloads) = load_bundle("collision-same-basename-configured-roots"); + let mut manifest = manifest_value(&manifest_json); + let fingerprint = + manifest["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"].clone(); + let lineage = manifest["artifacts"][0]["rotation"]["lineageId"].clone(); + manifest["artifacts"][0]["producerHostHandle"] = + Value::String("synthetic:host:site-01".to_owned()); + manifest["artifacts"][1]["configuredPathProvenance"]["pathFingerprint"] = fingerprint; + manifest["artifacts"][1]["rotation"]["lineageId"] = lineage; + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("the same source captured on distinct producer hosts is assessed"); + let serialized = serde_json::to_value(&assessment).expect("assessment serializes"); + assert_eq!( + serialized["coverage"], + json!([ + { + "producerRole": "managementPoint", + "producerHostHandle": "synthetic:host:mp-01", + "workflowSubjectRole": null, + "sourceId": "server-mp-policy", + "state": "captured", + "artifactIds": ["mp-policy-root-b-current"], + }, + { + "producerRole": "managementPoint", + "producerHostHandle": "synthetic:host:site-01", + "workflowSubjectRole": null, + "sourceId": "server-mp-policy", + "state": "captured", + "artifactIds": ["mp-policy-root-a-current"], + }, + ]), + "coverage membership must retain the physical producer that supplied each artifact", + ); + + let mut reordered_manifest = manifest.clone(); + reordered_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .reverse(); + let reordered = assess_server_intake(&serialize_manifest(&reordered_manifest), &payloads) + .expect("reordered distinct-host artifacts are assessed"); + assert_eq!( + serde_json::to_vec(&assessment).expect("assessment serializes"), + serde_json::to_vec(&reordered).expect("reordered assessment serializes"), + "topology-bound coverage must be byte-stable across manifest ordering", + ); +} + +#[test] +fn server_intake_scopes_path_fingerprint_lineage_to_producer_host() { + let (manifest_json, payloads) = load_bundle("collision-same-basename-configured-roots"); + let mut manifest = manifest_value(&manifest_json); + let fingerprint = + manifest["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"].clone(); + manifest["artifacts"][1]["producerHostHandle"] = + Value::String("synthetic:host:site-01".to_owned()); + manifest["artifacts"][1]["configuredPathProvenance"]["pathFingerprint"] = fingerprint; + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("path fingerprints are scoped to their producer host"); + assert_eq!(assessment.artifacts.len(), 2); +} + +fn configure_second_artifact_as_dp_identity( + manifest: &mut Value, + subject_handle: &str, + share_lineage: bool, +) { + let fingerprint = + manifest["artifacts"][2]["configuredPathProvenance"]["pathFingerprint"].clone(); + let lineage = manifest["artifacts"][2]["rotation"]["lineageId"].clone(); + let artifact = &mut manifest["artifacts"][3]; + artifact["workflowSubject"] = json!({ + "role": "distributionPoint", + "instanceHandle": subject_handle, + }); + artifact["sourceId"] = Value::String("server-dp-distribution".to_owned()); + artifact["originalPath"] = Value::String("REDACTED_SITE_DP_CONTROL_ROOT_COPY".to_owned()); + artifact["originalBasename"] = Value::String("distmgr.log".to_owned()); + artifact["configuredPathProvenance"]["pathFingerprint"] = fingerprint; + if share_lineage { + artifact["rotation"]["lineageId"] = lineage; + } + artifact["relativePath"] = Value::String( + "evidence/sccm/server/site-server/server-dp-distribution/subject-distribution-point/instance-bbbbbbbb/current/distmgr.log" + .to_owned(), + ); +} + +#[test] +fn server_intake_scopes_canonical_identity_to_workflow_subject() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][2]["workflowSubject"]["instanceHandle"] = + Value::String("synthetic:subject:dp-02".to_owned()); + configure_second_artifact_as_dp_identity(&mut manifest, "synthetic:subject:dp-01", true); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("the same artifact identity for a distinct workflow subject is independent"); + assert_eq!(assessment.artifacts.len(), 4); + assert_eq!( + assessment + .artifacts + .iter() + .filter(|artifact| artifact.source_id == "server-dp-distribution") + .map(|artifact| artifact.workflow_subject_handle.as_deref()) + .collect::>(), + vec![ + Some("synthetic:subject:dp-01"), + Some("synthetic:subject:dp-02"), + ], + "workflow-subject provenance orders otherwise-equal artifacts before caller ids", + ); + + let mut reordered_manifest = manifest.clone(); + reordered_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .reverse(); + let reordered = assess_server_intake(&serialize_manifest(&reordered_manifest), &payloads) + .expect("reordered distinct-subject artifacts are assessed"); + assert_eq!( + serde_json::to_vec(&assessment).expect("assessment serializes"), + serde_json::to_vec(&reordered).expect("reordered assessment serializes"), + "distinct-subject output is independent of manifest order", + ); +} + +#[test] +fn server_intake_coverage_binds_each_row_to_its_workflow_subject() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let mut manifest = manifest_value(&manifest_json); + configure_second_artifact_as_dp_identity(&mut manifest, "synthetic:subject:dp-02", false); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("the same source captured for distinct workflow subjects is assessed"); + let serialized = serde_json::to_value(&assessment).expect("assessment serializes"); + assert_eq!( + serialized["coverage"], + json!([ + { + "producerRole": "managementPoint", + "producerHostHandle": "synthetic:host:mp-01", + "workflowSubjectRole": null, + "sourceId": "server-mp-policy", + "state": "captured", + "artifactIds": ["mp-policy-current"], + }, + { + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "synthetic:subject:dp-01", + "sourceId": "server-dp-distribution", + "state": "captured", + "artifactIds": ["dp-dist-current"], + }, + { + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "synthetic:subject:dp-02", + "sourceId": "server-dp-distribution", + "state": "captured", + "artifactIds": ["sup-sync-current"], + }, + { + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "workflowSubjectRole": null, + "sourceId": "server-sitecomp", + "state": "captured", + "artifactIds": ["sitecomp-current"], + }, + ]), + "coverage membership must retain the exact workflow subject for each DP artifact", + ); + + let mut reordered_manifest = manifest.clone(); + reordered_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .reverse(); + let reordered = assess_server_intake(&serialize_manifest(&reordered_manifest), &payloads) + .expect("reordered distinct-subject artifacts are assessed"); + assert_eq!( + serde_json::to_vec(&assessment).expect("assessment serializes"), + serde_json::to_vec(&reordered).expect("reordered assessment serializes"), + "topology-bound coverage must be byte-stable across manifest ordering", + ); +} + +#[test] +fn server_intake_coverage_omits_absent_optional_topology_handles() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let assessment = + assess_server_intake(&manifest_json, &payloads).expect("complete bundle is assessed"); + let serialized = serde_json::to_value(&assessment).expect("assessment serializes"); + let management_point = serialized["coverage"] + .as_array() + .expect("coverage is an array") + .iter() + .find(|row| row["sourceId"] == "server-mp-policy") + .expect("management-point coverage is present"); + + assert_eq!( + management_point["producerHostHandle"], + Value::String("synthetic:host:mp-01".to_owned()), + "present producer topology stays additive in coverage JSON", + ); + assert!( + management_point.get("workflowSubjectHandle").is_none(), + "an absent optional workflow handle must not alter legacy coverage JSON", + ); +} + +#[test] +fn server_intake_scopes_path_fingerprint_lineage_to_workflow_subject() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let mut manifest = manifest_value(&manifest_json); + configure_second_artifact_as_dp_identity(&mut manifest, "synthetic:subject:dp-02", false); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("path fingerprints are scoped to their workflow subject"); + assert_eq!(assessment.artifacts.len(), 4); +} + +#[test] +fn server_intake_rejects_relabelled_duplicate_for_same_workflow_subject() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let mut manifest = manifest_value(&manifest_json); + configure_second_artifact_as_dp_identity(&mut manifest, "synthetic:subject:dp-01", true); + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::DuplicateArtifact), + "caller labels cannot split one host-and-subject artifact identity", + ); +} + +#[test] +fn server_intake_preserves_physical_parse_failure_provenance() { + let (manifest_json, payloads) = load_bundle("multiline"); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["captureState"] = Value::String("parseFailed".to_owned()); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("physical parse failure remains assessable"); + assert_eq!( + assessment.artifacts[0].state, + SccmCoverageState::ParseFailed + ); + assert!(assessment.evidence.is_empty()); + assert!(assessment.findings.is_empty()); + assert_eq!(assessment.next_artifact_requests.len(), 1); + + let serialized = serde_json::to_value(&assessment).expect("assessment serializes"); + let artifact = artifact_json(&serialized, "mp-policy-multiline"); + assert_eq!(artifact["bytesCopied"], 207); + assert_eq!(artifact["captureProvenance"]["schemaVersion"], 1); + assert_eq!(artifact["captureProvenance"]["encoding"], "utf-8"); + assert_eq!(artifact["captureProvenance"]["byteLimit"], 4096); + assert_eq!(artifact["captureProvenance"]["limitApplied"], false); + assert_eq!( + artifact["relativePath"], + "evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log" + ); +} + +#[test] +fn server_intake_converts_malformed_captured_ccm_to_parse_failed() { + let (manifest_json, mut payloads) = load_bundle("multiline"); + let mut manifest = manifest_value(&manifest_json); + payloads[0].bytes = b"not a complete CCM logical record".to_vec(); + manifest["artifacts"][0]["bytesCopied"] = Value::from(payloads[0].bytes.len() as u64); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("malformed collected bytes retain explicit partial coverage"); + assert_eq!( + assessment.artifacts[0].state, + SccmCoverageState::ParseFailed + ); + assert!(assessment.evidence.is_empty()); + assert!(assessment.findings.is_empty()); + assert_eq!(assessment.next_artifact_requests.len(), 1); + + let serialized = serde_json::to_value(&assessment).expect("assessment serializes"); + let artifact = artifact_json(&serialized, "mp-policy-multiline"); + assert_eq!(artifact["captureProvenance"]["encoding"], "utf-8"); + assert_eq!(artifact["captureProvenance"]["byteLimit"], 4096); + assert_eq!(artifact["captureProvenance"]["limitApplied"], false); +} + +#[test] +fn server_intake_projects_versioned_capture_provenance() { + let (captured_manifest, captured_payloads) = load_bundle("configured-nondefault-path"); + let captured = assess_server_intake(&captured_manifest, &captured_payloads) + .expect("captured bundle is assessed"); + let captured_json = serde_json::to_value(&captured).expect("assessment serializes"); + let captured_artifact = artifact_json(&captured_json, "mp-policy-configured"); + assert_eq!(captured_artifact["captureProvenance"]["schemaVersion"], 1); + assert_eq!(captured_artifact["captureProvenance"]["encoding"], "utf-8"); + assert_eq!(captured_artifact["captureProvenance"]["byteLimit"], 4096); + assert_eq!( + captured_artifact["captureProvenance"]["limitApplied"], + false + ); + + let (capped_manifest, capped_payloads) = load_bundle("capped-sup"); + let capped = assess_server_intake(&capped_manifest, &capped_payloads) + .expect("capped bundle is assessed"); + let capped_json = serde_json::to_value(&capped).expect("assessment serializes"); + let capped_artifact = artifact_json(&capped_json, "sup-sync-capped"); + assert_eq!(capped_artifact["captureProvenance"]["schemaVersion"], 1); + assert_eq!(capped_artifact["captureProvenance"]["encoding"], "utf-8"); + assert_eq!(capped_artifact["captureProvenance"]["byteLimit"], 64); + assert_eq!(capped_artifact["captureProvenance"]["limitApplied"], true); +} + +#[test] +fn server_intake_suppresses_absent_default_request_when_configured_source_is_usable() { + let (configured_manifest, configured_payloads) = load_bundle("configured-nondefault-path"); + let mut combined = manifest_value(&configured_manifest); + let (absent_manifest, _absent_payloads) = load_bundle("access-denied-mp"); + let mut absent = manifest_value(&absent_manifest)["artifacts"][0].clone(); + absent["captureState"] = Value::String("absent".to_owned()); + absent["collectionDetail"] = Value::Null; + absent["configuredPathProvenance"]["state"] = Value::String("defaultCandidate".to_owned()); + combined["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .push(absent); + + let assessment = assess_server_intake(&serialize_manifest(&combined), &configured_payloads) + .expect("compatible configured and default candidates are assessed together"); + assert_eq!(assessment.coverage.len(), 2); + assert!(assessment + .coverage + .iter() + .any(|row| row.state == SccmCoverageState::Captured)); + assert!(assessment + .coverage + .iter() + .any(|row| row.state == SccmCoverageState::Absent)); + assert!( + assessment.next_artifact_requests.is_empty(), + "a usable configured candidate satisfies the logical source request" + ); +} + +#[test] +fn server_intake_does_not_suppress_default_request_across_producer_hosts() { + let (configured_manifest, configured_payloads) = load_bundle("configured-nondefault-path"); + let mut combined = manifest_value(&configured_manifest); + let (absent_manifest, _absent_payloads) = load_bundle("access-denied-mp"); + let mut absent = manifest_value(&absent_manifest)["artifacts"][0].clone(); + absent["producerHostHandle"] = Value::String("synthetic:host:site-01".to_owned()); + absent["captureState"] = Value::String("absent".to_owned()); + absent["collectionDetail"] = Value::Null; + absent["configuredPathProvenance"]["state"] = Value::String("defaultCandidate".to_owned()); + combined["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .push(absent); + + let assessment = assess_server_intake(&serialize_manifest(&combined), &configured_payloads) + .expect("distinct-host configured and default candidates are assessed together"); + assert_eq!(assessment.next_artifact_requests.len(), 1); + assert_eq!( + assessment.next_artifact_requests[0].logical_id, + "mpGetPolicy" + ); +} + +#[test] +fn server_intake_does_not_suppress_default_request_across_workflow_subjects() { + let (captured_manifest, captured_payloads) = load_bundle("complete-multi-role"); + let mut combined = manifest_value(&captured_manifest); + let (absent_manifest, _absent_payloads) = load_bundle("absent-dp"); + let mut absent = manifest_value(&absent_manifest)["artifacts"][0].clone(); + absent["workflowSubject"] = json!({ + "role": "distributionPoint", + "instanceHandle": "synthetic:subject:dp-02", + }); + combined["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .push(absent); + + let assessment = assess_server_intake(&serialize_manifest(&combined), &captured_payloads) + .expect("distinct-subject configured and default candidates are assessed together"); + assert_eq!(assessment.next_artifact_requests.len(), 1); + assert_eq!(assessment.next_artifact_requests[0].logical_id, "distmgr"); +} + +#[test] +fn server_intake_exercises_role_state_rotation_and_privacy_matrix() { + let cases = [ + ( + "configured-nondefault-path", + SccmCoverageState::Captured, + 1, + 0, + ), + ("absent-dp", SccmCoverageState::Absent, 0, 1), + ("access-denied-mp", SccmCoverageState::AccessDenied, 0, 1), + ("capped-sup", SccmCoverageState::Capped, 0, 1), + ("skipped-iis", SccmCoverageState::Skipped, 0, 0), + ( + "unsupported-db-supplement", + SccmCoverageState::Unsupported, + 0, + 0, + ), + ]; + for (scenario, state, evidence_count, request_count) in cases { + let (manifest, payloads) = load_bundle(scenario); + let assessment = assess_server_intake(&manifest, &payloads) + .unwrap_or_else(|error| panic!("{scenario} should be assessed: {error}")); + assert_eq!(assessment.coverage[0].state, state, "{scenario}"); + assert_eq!(assessment.evidence.len(), evidence_count, "{scenario}"); + assert_eq!( + assessment.next_artifact_requests.len(), + request_count, + "{scenario}" + ); + assert!(assessment.findings.is_empty(), "{scenario}"); + let public_json = serde_json::to_string(&assessment).expect("assessment serializes"); + assert!(!public_json.contains("REDACTED_"), "{scenario}"); + assert!(!public_json.contains("LAB-"), "{scenario}"); + } + + let (rotations_manifest, rotations_payloads) = load_bundle("rotations"); + let rotations = assess_server_intake(&rotations_manifest, &rotations_payloads) + .expect("declared rotations are assessed"); + assert_eq!( + rotations + .artifacts + .iter() + .map(|artifact| artifact.rotation.clone()) + .collect::>(), + vec![ + Some(SccmRotation::Timestamped("20260729-235700".to_owned())), + Some(SccmRotation::Numbered(2)), + Some(SccmRotation::LoUnderscore), + Some(SccmRotation::Current), + ] + ); + + let mut unknown_rotation = manifest_value(&rotations_manifest); + unknown_rotation["artifacts"][0]["rotation"]["kind"] = Value::String("unknown".to_owned()); + unknown_rotation["artifacts"][0]["rotation"]["value"] = Value::Null; + assert!( + assess_server_intake(&serialize_manifest(&unknown_rotation), &rotations_payloads).is_err(), + "unknown rotations fail closed" + ); + + let (complete_manifest, complete_payloads) = load_bundle("complete-multi-role"); + let complete = assess_server_intake(&complete_manifest, &complete_payloads) + .expect("role-aware bundle is assessed"); + assert_eq!( + complete.topology.capture_host_handle, + "synthetic:host:lab-cm01" + ); + assert_eq!( + complete.topology.roles_observed, + vec![ + SccmRole::DistributionPoint, + SccmRole::ManagementPoint, + SccmRole::SiteServer, + SccmRole::SoftwareUpdatePoint, + ] + ); +} + +#[test] +fn server_intake_marks_an_incomplete_tail_as_a_parse_gap_even_after_valid_evidence() { + let (manifest_json, mut payloads) = load_bundle("multiline"); + let mut manifest = manifest_value(&manifest_json); + payloads[0] + .bytes + .extend_from_slice(b"\n {} + Err(error) => unexpected.push(format!("{name}: returned {error:?}")), + Ok(_) => unexpected.push(format!("{name}: was accepted")), + } + } + + assert!( + unexpected.is_empty(), + "WSUS supplemental tuple mutations must fail closed:\n{}", + unexpected.join("\n") + ); +} + +#[test] +fn server_intake_rejects_timestamped_rotation_for_synthetic_wsus_tuple() { + let (manifest_json, payloads) = load_bundle("supplemental-wsus-skipped"); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["rotation"]["kind"] = Value::String("timestamped".to_owned()); + manifest["artifacts"][0]["rotation"]["value"] = Value::String("20260729-235700".to_owned()); + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::InvalidArtifact), + "the frozen synthetic WSUS tuple cannot discard a valid timestamped rotation" + ); +} + +#[test] +fn server_intake_rejects_provider_defined_value_for_synthetic_wsus_tuple() { + let (manifest_json, payloads) = load_bundle("supplemental-wsus-skipped"); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["rotation"]["value"] = + Value::String("unexpected-provider-value".to_owned()); + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::InvalidArtifact), + "the frozen synthetic WSUS provider-defined rotation cannot carry a value" + ); +} + +#[test] +fn server_intake_accepts_profile_validated_production_wsus_tuple_with_opaque_provenance() { + let (manifest_json, payloads) = load_bundle("supplemental-wsus-skipped"); + let mut manifest = manifest_value(&manifest_json); + let artifact_id = opaque_handle("cmtraceopen.artifact.sha256.v1:", 201); + + manifest["syntheticFixture"] = Value::Bool(false); + manifest + .as_object_mut() + .expect("manifest is an object") + .remove("proposalOnly"); + manifest + .as_object_mut() + .expect("manifest is an object") + .remove("privacy"); + manifest["topology"]["captureHost"] = + Value::String(opaque_handle("cmtraceopen.host.sha256.v1:", 202)); + manifest["topology"]["siteCode"] = + Value::String(opaque_handle("cmtraceopen.site.sha256.v1:", 203)); + manifest["artifacts"][0]["artifactId"] = Value::String(artifact_id.clone()); + manifest["artifacts"][0]["producerHostHandle"] = + Value::String(opaque_handle("cmtraceopen.host.sha256.v1:", 204)); + manifest["artifacts"][0]["workflowSubject"]["instanceHandle"] = + Value::String(opaque_handle("cmtraceopen.subject.sha256.v1:", 205)); + manifest["artifacts"][0]["sourceVersion"] = Value::String("5.00.9999.9999".to_owned()); + manifest["artifacts"][0]["originalPath"] = Value::String("REDACTED".to_owned()); + manifest["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"] = + Value::String(opaque_handle("cmtraceopen.path.sha256.v1:", 206)); + manifest["artifacts"][0]["rotation"]["lineageId"] = + Value::String(opaque_handle("cmtraceopen.lineage.sha256.v1:", 207)); + manifest["artifacts"][0]["skipReason"] = + Value::String(opaque_handle("cmtraceopen.skip-reason.sha256.v1:", 208)); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("profile-validated production WSUS provenance remains assessable"); + let public = serde_json::to_value(assessment).expect("assessment serializes"); + let artifact = artifact_json(&public, &artifact_id); + assert_eq!(artifact["producerRole"], "wsUs"); + assert_eq!(artifact["workflowSubjectRole"], "softwareUpdatePoint"); + assert_eq!(artifact["sourceVersion"], "5.00.9999.9999"); + assert_eq!(artifact["state"], "skipped"); +} + +#[test] +fn server_intake_rejects_opaque_future_source_ids_in_synthetic_fixtures() { + let (manifest_json, payloads) = load_bundle("unsupported-db-supplement"); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["sourceId"] = + Value::String(opaque_handle("cmtraceopen.source.sha256.v1:", 77)); + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::InvalidArtifact), + "synthetic fixtures must use the frozen public source vocabulary" + ); +} + +#[test] +fn server_intake_rejects_identity_bearing_unsupported_public_provenance() { + for (field, marker) in [ + ("sourceId", "realuser-example"), + ("sourceKind", "RealUser"), + ("originalBasename", "RealUser.log"), + ] { + assert_unsafe_mutation_is_rejected("unsupported-db-supplement", marker, |manifest, _| { + manifest["artifacts"][0][field] = Value::String(marker.to_owned()); + }); + } +} + +#[test] +fn server_intake_accepts_only_opaque_future_unsupported_provenance() { + let (manifest_json, _) = bounded_manifest(1, 4_096); + let mut manifest = manifest_value(&manifest_json); + let source_id = opaque_handle("cmtraceopen.source.sha256.v1:", 1); + let source_kind = opaque_handle("cmtraceopen.source-kind.sha256.v1:", 2); + let basename = opaque_handle("cmtraceopen.basename.sha256.v1:", 3); + let artifact = &mut manifest["artifacts"][0]; + artifact["producerRole"] = Value::String("unclassified".to_owned()); + artifact["producerHostHandle"] = Value::Null; + artifact["sourceId"] = Value::String(source_id.clone()); + artifact["sourceKind"] = Value::String(source_kind.clone()); + artifact["originalBasename"] = Value::String(basename.clone()); + artifact["rotation"] = json!({ + "kind": "none", + "lineageId": opaque_handle("cmtraceopen.lineage.sha256.v1:", 4), + }); + artifact["captureState"] = Value::String("unsupported".to_owned()); + artifact["encoding"] = Value::Null; + artifact["collectionLimit"] = Value::Null; + artifact["relativePath"] = Value::Null; + artifact["bytesCopied"] = Value::from(0); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &[]) + .expect("opaque future unsupported provenance remains retainable"); + let public = serde_json::to_value(&assessment).expect("assessment serializes"); + assert_eq!(public["artifacts"][0]["sourceId"], source_id); + assert_eq!(public["artifacts"][0]["sourceKind"], source_kind); + assert_eq!(public["artifacts"][0]["originalBasename"], basename); +} + +#[test] +fn server_manifest_v1_retains_only_versioned_opaque_extensions_deterministically() { + let (manifest_json, payloads) = bounded_manifest(1, 4_096); + let extension_name_a = "x-cmtraceopen-opaque-v1-alpha"; + let extension_name_b = "x-cmtraceopen-opaque-v1-beta"; + let extension_value_a = opaque_handle("cmtraceopen.extension.sha256.v1:", 1); + let extension_value_b = opaque_handle("cmtraceopen.extension.sha256.v1:", 2); + let scopes = ["manifest", "topology", "artifact"]; + let beta_then_alpha = manifest_with_ordered_extensions( + &manifest_json, + &scopes, + &[ + (extension_name_b, extension_value_b.as_str()), + (extension_name_a, extension_value_a.as_str()), + ], + ); + let alpha_then_beta = manifest_with_ordered_extensions( + &manifest_json, + &scopes, + &[ + (extension_name_a, extension_value_a.as_str()), + (extension_name_b, extension_value_b.as_str()), + ], + ); + assert_ne!( + beta_then_alpha, alpha_then_beta, + "the test inputs must preserve genuinely different extension arrival orders" + ); + assert!( + beta_then_alpha + .find(extension_name_b) + .expect("beta extension is present") + < beta_then_alpha + .find(extension_name_a) + .expect("alpha extension is present"), + "the first raw manifest must place beta before alpha" + ); + assert!( + alpha_then_beta + .find(extension_name_a) + .expect("alpha extension is present") + < alpha_then_beta + .find(extension_name_b) + .expect("beta extension is present"), + "the reordered raw manifest must place alpha before beta" + ); + + let assessment = assess_server_intake(&beta_then_alpha, &payloads) + .expect("versioned opaque extensions are retained"); + let public = serde_json::to_value(&assessment).expect("assessment serializes"); + let expected = json!([ + { "schemaVersion": 1, "name": extension_name_a, "value": extension_value_a }, + { "schemaVersion": 1, "name": extension_name_b, "value": extension_value_b }, + ]); + assert_eq!(public["extensions"], expected); + assert_eq!(public["topology"]["extensions"], expected); + assert_eq!(public["artifacts"][0]["extensions"], expected); + + let reordered_assessment = assess_server_intake(&alpha_then_beta, &payloads) + .expect("extension arrival order does not change normalized output"); + assert_eq!(assessment, reordered_assessment); +} + +#[test] +fn server_manifest_version_gate_precedes_v1_extension_validation() { + let (manifest_json, payloads) = bounded_manifest(1, 4_096); + let mut manifest = manifest_value(&manifest_json); + manifest["sccmManifestVersion"] = Value::from(2); + manifest["futureManifestField"] = json!({ "shape": "belongs-to-v2" }); + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::UnsupportedManifestVersion), + "unsupported versions are rejected by the version gate before applying the v1 extension grammar" + ); +} + +#[test] +fn server_manifest_known_metadata_errors_route_to_manifest_scope() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let mut wrong_results = Vec::new(); + + for case in [ + "missingPrivacy", + "invalidPrivacySynthetic", + "invalidPrivacyRawPaths", + "missingProposalOnly", + "invalidProposalOnly", + "invalidInputOrderDeclaration", + ] { + let mut manifest = manifest_value(&manifest_json); + match case { + "missingPrivacy" => { + manifest + .as_object_mut() + .expect("manifest is an object") + .remove("privacy"); + } + "invalidPrivacySynthetic" => manifest["privacy"]["synthetic"] = Value::Bool(false), + "invalidPrivacyRawPaths" => { + manifest["privacy"]["rawPaths"] = Value::String("raw".to_owned()); + } + "missingProposalOnly" => { + manifest + .as_object_mut() + .expect("manifest is an object") + .remove("proposalOnly"); + } + "invalidProposalOnly" => manifest["proposalOnly"] = Value::Bool(false), + "invalidInputOrderDeclaration" => { + manifest["inputOrderIsDeliberatelyUnsorted"] = Value::Bool(false); + } + _ => unreachable!(), + } + let actual = assess_server_intake(&serialize_manifest(&manifest), &payloads); + if actual != Err(SccmServerIntakeError::MalformedManifest) { + wrong_results.push((case, actual)); + } + } + + assert!( + wrong_results.is_empty(), + "manifest/privacy known-field errors must stay in manifest scope: {wrong_results:?}" + ); +} + +#[test] +fn server_manifest_v1_rejects_unversioned_or_nonopaque_unknown_fields() { + let (manifest_json, payloads) = bounded_manifest(1, 4_096); + + for path in ["manifest", "topology", "artifact"] { + for (name, value) in [ + ("unexpectedEvidence", Value::Bool(true)), + ( + "x-cmtraceopen-opaque-v1-arbitrary", + Value::String("identity-bearing text".to_owned()), + ), + ] { + let mut manifest = manifest_value(&manifest_json); + match path { + "manifest" => manifest[name] = value, + "topology" => manifest["topology"][name] = value, + "artifact" => manifest["artifacts"][0][name] = value, + _ => unreachable!(), + } + let expected = match path { + "manifest" => SccmServerIntakeError::MalformedManifest, + "topology" => SccmServerIntakeError::InvalidTopology, + "artifact" => SccmServerIntakeError::InvalidArtifact, + _ => unreachable!(), + }; + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(expected), + "{path} must reject arbitrary extension {name} in its own scope" + ); + } + } +} + +#[test] +fn server_manifest_v1_rejects_duplicate_opaque_fields_in_every_scope() { + let (production_json, production_payloads) = bounded_manifest(1, 4_096); + let (synthetic_json, synthetic_payloads) = load_bundle("complete-multi-role"); + let synthetic_json = serialize_manifest(&manifest_value(&synthetic_json)); + let extension_name = "x-cmtraceopen-opaque-v1-duplicate"; + let first_value = opaque_handle("cmtraceopen.extension.sha256.v1:", 31); + let second_value = opaque_handle("cmtraceopen.extension.sha256.v1:", 32); + let mut wrong_results = Vec::new(); + + for (scope, manifest_json, payloads, expected) in [ + ( + "manifest", + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::MalformedManifest, + ), + ( + "privacy", + synthetic_json.as_str(), + synthetic_payloads.as_slice(), + SccmServerIntakeError::MalformedManifest, + ), + ( + "topology", + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidTopology, + ), + ( + "artifact", + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ( + "workflowSubject", + synthetic_json.as_str(), + synthetic_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ( + "configuredPathProvenance", + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ( + "rotation", + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ( + "collectionLimit", + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ] { + let manifest = manifest_with_duplicate_extension( + manifest_json, + scope, + extension_name, + &first_value, + &second_value, + ); + let actual = assess_server_intake(&manifest, payloads); + if actual != Err(expected.clone()) { + wrong_results.push((scope, actual, expected)); + } + } + + assert!( + wrong_results.is_empty(), + "duplicate extension results must be scope exact: {wrong_results:?}" + ); +} + +#[test] +fn server_manifest_v1_rejects_duplicate_known_fields_in_every_extension_scope() { + let (production_json, production_payloads) = bounded_manifest(1, 4_096); + let (synthetic_json, synthetic_payloads) = load_bundle("complete-multi-role"); + let synthetic_json = serialize_manifest(&manifest_value(&synthetic_json)); + let mut wrong_results = Vec::new(); + + for (scope, field, value, manifest_json, payloads, expected) in [ + ( + "manifest", + "sccmManifestVersion", + json!(1), + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::MalformedManifest, + ), + ( + "privacy", + "synthetic", + json!(true), + synthetic_json.as_str(), + synthetic_payloads.as_slice(), + SccmServerIntakeError::MalformedManifest, + ), + ( + "topology", + "captureHost", + json!("duplicate-host"), + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidTopology, + ), + ( + "artifact", + "sourceId", + json!("server-mp-policy"), + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ( + "workflowSubject", + "role", + json!("distributionPoint"), + synthetic_json.as_str(), + synthetic_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ( + "configuredPathProvenance", + "state", + json!("configured"), + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ( + "rotation", + "kind", + json!("current"), + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ( + "collectionLimit", + "byteLimit", + json!(4_096), + production_json.as_str(), + production_payloads.as_slice(), + SccmServerIntakeError::InvalidArtifact, + ), + ] { + let manifest = manifest_with_duplicate_known_field(manifest_json, scope, field, &value); + let actual = assess_server_intake(&manifest, payloads); + if actual != Err(expected.clone()) { + wrong_results.push((scope, actual, expected)); + } + } + + assert!( + wrong_results.is_empty(), + "duplicate known-field results must be scope exact: {wrong_results:?}" + ); +} + +#[test] +fn server_manifest_v1_retains_safe_nested_extensions_without_interpreting_them() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let mut manifest = manifest_value(&manifest_json); + let extension_name = "x-cmtraceopen-opaque-v1-nested"; + let extension_value = opaque_handle("cmtraceopen.extension.sha256.v1:", 41); + manifest["privacy"][extension_name] = Value::String(extension_value.clone()); + let artifact = &mut manifest["artifacts"][2]; + artifact["workflowSubject"][extension_name] = Value::String(extension_value.clone()); + artifact["configuredPathProvenance"][extension_name] = Value::String(extension_value.clone()); + artifact["rotation"][extension_name] = Value::String(extension_value.clone()); + artifact["collectionLimit"][extension_name] = Value::String(extension_value.clone()); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("safe nested extensions are retained as inert provenance"); + let public = serde_json::to_value(&assessment).expect("assessment serializes"); + let expected = json!([{ + "schemaVersion": 1, + "name": extension_name, + "value": extension_value, + }]); + assert_eq!(public["privacyExtensions"], expected); + let artifact = artifact_json(&public, "dp-dist-current"); + assert_eq!(artifact["workflowSubjectExtensions"], expected); + assert_eq!(artifact["configuredPathProvenanceExtensions"], expected); + assert_eq!(artifact["rotationExtensions"], expected); + assert_eq!(artifact["collectionLimitExtensions"], expected); + assert!(assessment.evidence.iter().all(|evidence| { + !evidence.message.contains(extension_name) && !evidence.message.contains(&extension_value) + })); + assert!(assessment.findings.is_empty()); +} + +#[test] +fn server_manifest_v1_rejects_unsafe_nested_extensions_in_their_scope() { + let (manifest_json, payloads) = load_bundle("complete-multi-role"); + let extension_name = "x-cmtraceopen-opaque-v1-nested-unsafe"; + let mut wrong_results = Vec::new(); + + for (scope, expected) in [ + ("privacy", SccmServerIntakeError::MalformedManifest), + ("workflowSubject", SccmServerIntakeError::InvalidArtifact), + ( + "configuredPathProvenance", + SccmServerIntakeError::InvalidArtifact, + ), + ("rotation", SccmServerIntakeError::InvalidArtifact), + ("collectionLimit", SccmServerIntakeError::InvalidArtifact), + ] { + let mut manifest = manifest_value(&manifest_json); + match scope { + "privacy" => manifest["privacy"][extension_name] = json!({ "identity": "real-user" }), + "workflowSubject" => { + manifest["artifacts"][2]["workflowSubject"][extension_name] = + json!({ "identity": "real-user" }); + } + "configuredPathProvenance" => { + manifest["artifacts"][2]["configuredPathProvenance"][extension_name] = + json!({ "identity": "real-user" }); + } + "rotation" => { + manifest["artifacts"][2]["rotation"][extension_name] = + json!({ "identity": "real-user" }); + } + "collectionLimit" => { + manifest["artifacts"][2]["collectionLimit"][extension_name] = + json!({ "identity": "real-user" }); + } + _ => unreachable!(), + } + let actual = assess_server_intake(&serialize_manifest(&manifest), &payloads); + if actual != Err(expected.clone()) { + wrong_results.push((scope, actual, expected)); + } + } + + assert!( + wrong_results.is_empty(), + "unsafe nested extension results must be scope exact: {wrong_results:?}" + ); +} + +#[test] +fn server_intake_retains_only_opaque_future_roles_as_unsupported_coverage() { + let (manifest_json, _) = bounded_manifest(1, 4_096); + let mut manifest = manifest_value(&manifest_json); + let future_role = opaque_handle("cmtraceopen.role.sha256.v1:", 9); + manifest["topology"]["rolesObserved"] = json!(["managementPoint", future_role]); + let artifact = &mut manifest["artifacts"][0]; + artifact["producerRole"] = Value::String(future_role.clone()); + artifact["producerHostHandle"] = Value::Null; + artifact["sourceId"] = Value::String(opaque_handle("cmtraceopen.source.sha256.v1:", 1)); + artifact["sourceKind"] = Value::String(opaque_handle("cmtraceopen.source-kind.sha256.v1:", 2)); + artifact["originalBasename"] = + Value::String(opaque_handle("cmtraceopen.basename.sha256.v1:", 3)); + artifact["originalPath"] = + Value::String(opaque_handle("cmtraceopen.original-path.sha256.v1:", 4)); + artifact["rotation"] = json!({ + "kind": "none", + "lineageId": opaque_handle("cmtraceopen.lineage.sha256.v1:", 5), + }); + artifact["captureState"] = Value::String("unsupported".to_owned()); + artifact["encoding"] = Value::Null; + artifact["collectionLimit"] = Value::Null; + artifact["relativePath"] = Value::Null; + artifact["bytesCopied"] = Value::from(0); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &[]) + .expect("opaque future role is retained as unsupported provenance"); + let public = serde_json::to_value(&assessment).expect("assessment serializes"); + assert!(public["topology"]["rolesObserved"] + .as_array() + .expect("roles observed is an array") + .contains(&Value::String(future_role.clone()))); + assert_eq!(public["artifacts"][0]["producerRole"], future_role); + assert_eq!(public["coverage"][0]["state"], "unsupported"); + assert!(!assessment.artifacts[0].parser_eligible); + assert!(assessment.evidence.is_empty()); + assert!(assessment.findings.is_empty()); + assert!(assessment.next_artifact_requests.is_empty()); +} + +#[test] +fn server_intake_retains_opaque_future_roles_across_conservative_coverage_states() { + let mut failures = Vec::new(); + + for (wire_state, expected_state, ordinal) in [ + ("absent", SccmCoverageState::Absent, 101), + ("accessDenied", SccmCoverageState::AccessDenied, 102), + ("capped", SccmCoverageState::Capped, 103), + ("unsupported", SccmCoverageState::Unsupported, 104), + ] { + let (manifest_json, payloads, future_role, expected_relative_path) = + opaque_future_role_manifest(wire_state, ordinal); + let result = (|| -> Result<(), String> { + let assessment = assess_server_intake(&manifest_json, &payloads) + .map_err(|error| format!("intake rejected the state: {error:?}"))?; + let expected_role = SccmRole::Unknown(future_role.clone()); + if !assessment.topology.roles_observed.contains(&expected_role) { + return Err("future role was not retained in topology".to_owned()); + } + let artifact = assessment + .artifacts + .iter() + .find(|artifact| artifact.producer_role == expected_role) + .ok_or_else(|| "future-role artifact was not retained".to_owned())?; + if artifact.state != expected_state { + return Err(format!( + "coverage changed from {expected_state:?} to {:?}", + artifact.state + )); + } + if artifact.parser_eligible { + return Err("future-role artifact became parser eligible".to_owned()); + } + if artifact.relative_path != expected_relative_path { + return Err(format!( + "relative path mismatch: {:?}", + artifact.relative_path + )); + } + if expected_state == SccmCoverageState::Capped { + let provenance = artifact + .capture_provenance + .as_ref() + .ok_or_else(|| "capped artifact lost capture provenance".to_owned())?; + if provenance.schema_version != 1 + || provenance.encoding != "utf-8" + || provenance.byte_limit != 16 + || !provenance.limit_applied + || artifact.bytes_copied != 16 + || artifact.truncated != Some(true) + || artifact.fragment_complete != Some(false) + || artifact.content_sha256.is_none() + { + return Err(format!("capped provenance was incoherent: {artifact:?}")); + } + } else if artifact.capture_provenance.is_some() + || artifact.content_sha256.is_some() + || artifact.bytes_copied != 0 + { + return Err(format!( + "nonphysical state retained physical provenance: {artifact:?}" + )); + } + if assessment.coverage.len() != 1 + || assessment.coverage[0].state != expected_state + || !assessment.evidence.is_empty() + || !assessment.findings.is_empty() + || !assessment.next_artifact_requests.is_empty() + { + return Err(format!( + "future-role state influenced diagnostics: {assessment:?}" + )); + } + Ok(()) + })(); + if let Err(error) = result { + failures.push((wire_state, error)); + } + } + + assert!( + failures.is_empty(), + "future-role coverage states must remain inert and exact: {failures:?}" + ); +} + +#[test] +fn server_intake_rejects_identity_bearing_future_roles() { + let (manifest_json, _) = bounded_manifest(1, 4_096); + let mut manifest = manifest_value(&manifest_json); + manifest["topology"]["rolesObserved"] = json!(["managementPoint", "real-server-role"]); + let artifact = &mut manifest["artifacts"][0]; + artifact["producerRole"] = Value::String("real-server-role".to_owned()); + artifact["producerHostHandle"] = Value::Null; + artifact["captureState"] = Value::String("unsupported".to_owned()); + artifact["encoding"] = Value::Null; + artifact["collectionLimit"] = Value::Null; + artifact["relativePath"] = Value::Null; + artifact["bytesCopied"] = Value::from(0); + + assert!( + assess_server_intake(&serialize_manifest(&manifest), &[]).is_err(), + "identity-bearing future roles are not public provenance" + ); +} + +#[test] +fn server_intake_rejects_future_topology_roles_without_a_matching_artifact() { + let (manifest_json, payloads) = bounded_manifest(1, 4_096); + let mut manifest = manifest_value(&manifest_json); + manifest["topology"]["rolesObserved"] = json!([ + "managementPoint", + opaque_handle("cmtraceopen.role.sha256.v1:", 10), + ]); + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::InvalidTopology), + "every future topology role must have retained artifact provenance" + ); +} + +#[test] +fn server_intake_rejects_future_role_artifacts_missing_from_topology() { + let (manifest_json, payloads, _, _) = opaque_future_role_manifest("unsupported", 105); + let mut manifest = manifest_value(&manifest_json); + manifest["topology"]["rolesObserved"] = json!(["managementPoint"]); + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::InvalidArtifact), + "future producer provenance must be declared by topology" + ); +} + +#[test] +fn server_intake_rejects_hashed_future_roles_in_synthetic_fixtures() { + let (manifest_json, _) = load_bundle("unsupported-db-supplement"); + let mut manifest = manifest_value(&manifest_json); + let future_role = opaque_handle("cmtraceopen.role.sha256.v1:", 42); + manifest["topology"]["rolesObserved"] = json!(["managementPoint", future_role]); + manifest["artifacts"][0]["producerRole"] = Value::String(future_role); + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &[]), + Err(SccmServerIntakeError::InvalidTopology), + "synthetic fixtures keep the finite committed role vocabulary" + ); +} + +#[test] +fn server_intake_production_original_path_must_be_redacted_or_opaque() { + let (manifest_json, payloads) = bounded_manifest(1, 4_096); + let mut arbitrary = manifest_value(&manifest_json); + arbitrary["artifacts"][0]["originalPath"] = + Value::String("C:/Users/real-user/SMS_CCM/Logs/MP_GetPolicy.log".to_owned()); + assert!( + assess_server_intake(&serialize_manifest(&arbitrary), &payloads).is_err(), + "production originalPath cannot contradict the redacted privacy declaration" + ); + + let mut opaque = manifest_value(&manifest_json); + let path_handle = opaque_handle("cmtraceopen.original-path.sha256.v1:", 6); + opaque["artifacts"][0]["originalPath"] = Value::String(path_handle.clone()); + let assessment = assess_server_intake(&serialize_manifest(&opaque), &payloads) + .expect("an opaque production originalPath marker is safe"); + let serialized = serde_json::to_string(&assessment).expect("assessment serializes"); + assert!(!serialized.contains(&path_handle)); +} + +#[test] +fn server_intake_expected_oracle_has_no_unhandled_contract_keys() { + let currently_asserted = [ + "artifactId", + "artifactIdDerivationIgnoresDiscoveryOrder", + "artifactIdUniquenessScope", + "artifactProvenance", + "canonicalArtifactIds", + "canonicalRotationArtifactIds", + "collisionAssertions", + "collisionSafe", + "completeCcmRecordCount", + "configuredPathProvenance", + "coverage", + "crossBundleArtifactIdReuseAllowed", + "databaseOrRoleFinding", + "defaultCandidateInterpretation", + "deterministicEvidenceIds", + "eligibleForRoleReducer", + "evidence", + "forbiddenConclusion", + "lineageId", + "logicalRecordParseable", + "nextArtifactRequest", + "nonCapturedProvenance", + "normalizedOutputByteIdenticalWhenReordered", + "partialPhysicalFragmentCreatesTerminalResult", + "pre318ExpectedVersion", + "privacy", + "rawByteCountedBeforeDecoding", + "requiredSourceFailure", + "retainedUnclassifiedArtifactIds", + "roleHealthFinding", + "roleInference", + "rolesObserved", + "serializationOrderIsChronology", + "terminalManagementPointDiagnosis", + "terminalSoftwareUpdatePointHealth", + "totalRotationSort", + "uniqueRelativePaths", + ] + .into_iter() + .map(str::to_owned) + .collect::>(); + let all_expected = intake_scenarios() + .into_iter() + .flat_map(|scenario| { + load_expected(&scenario) + .as_object() + .expect("expected contract is an object") + .keys() + .cloned() + .collect::>() + }) + .collect::>(); + + assert_eq!( + currently_asserted, all_expected, + "every committed expected.json key must drive an assertion" + ); +} + +#[test] +fn server_intake_expected_oracles_do_not_claim_native_collection_acceptance() { + let forbidden_native_claims = BTreeSet::from([ + "atomicCreateNoOverwrite", + "destinationsPrecomputed", + "neitherOverwritten", + ]); + + for scenario in intake_scenarios() { + let expected = load_expected(&scenario); + let asserted_collision_keys = expected["collisionAssertions"] + .as_object() + .map(|assertions| assertions.keys().map(String::as_str).collect()) + .unwrap_or_default(); + assert!( + forbidden_native_claims.is_disjoint(&asserted_collision_keys), + "{scenario}: pure parser oracles cannot claim native collection acceptance" + ); + } +} + +#[test] +fn server_intake_rejects_ambiguous_retained_unknown_rotations() { + for (kind, value) in [ + ("future", Value::Null), + ("current", Value::String("unexpected".to_owned())), + ("none", Value::String("unexpected".to_owned())), + ("timestamped", Value::String("not-a-timestamp".to_owned())), + ] { + let (manifest_json, payloads) = load_bundle("unsupported-db-supplement"); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["rotation"]["kind"] = Value::String(kind.to_owned()); + manifest["artifacts"][0]["rotation"]["value"] = value; + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::InvalidArtifact), + "retained unknown evidence needs an unambiguous rotation identity" + ); + } +} + +#[test] +fn server_intake_bounds_each_declared_artifact_limit() { + let (manifest_json, payloads) = bounded_manifest(1, 268_435_457); + + assert_eq!( + assess_server_intake(&manifest_json, &payloads), + Err(SccmServerIntakeError::ManifestLimitExceeded), + "a single artifact may not declare more than 256 MiB" + ); +} + +#[test] +fn server_intake_accepts_a_manifest_within_all_resource_limits() { + let (manifest_json, payloads) = bounded_manifest(1, 4_096); + + assert!( + assess_server_intake(&manifest_json, &payloads).is_ok(), + "the bounded-manifest helper must represent a valid baseline" + ); +} + +#[test] +fn server_intake_bounds_manifest_artifact_count() { + let (manifest_json, payloads) = bounded_manifest(513, 4_096); + + assert_eq!( + assess_server_intake(&manifest_json, &payloads), + Err(SccmServerIntakeError::ManifestLimitExceeded), + "a manifest may not force unbounded per-artifact work" + ); +} + +#[test] +fn server_intake_artifact_count_limit_precedes_per_artifact_extension_work() { + let (manifest_json, payloads) = bounded_manifest(513, 4_096); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["unsafeIdentityField"] = + Value::String("Real User ".to_owned()); + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::ManifestLimitExceeded), + "the artifact-count gate must stop nested preflight work before artifact validation" + ); +} + +#[test] +fn server_intake_bounds_aggregate_declared_bytes() { + let (manifest_json, payloads) = bounded_manifest(5, 268_435_456); + + assert_eq!( + assess_server_intake(&manifest_json, &payloads), + Err(SccmServerIntakeError::ManifestLimitExceeded), + "aggregate declared collection work may not exceed 1 GiB" + ); +} + +#[test] +fn server_intake_bounds_aggregate_bytes_copied_without_collection_limits() { + let (manifest_json, payloads) = bounded_manifest(5, 1); + let mut manifest = manifest_value(&manifest_json); + for artifact in manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + { + artifact["bytesCopied"] = Value::from(268_435_456_u64); + artifact["collectionLimit"] = Value::Null; + } + + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::ManifestLimitExceeded), + "aggregate copied-byte claims stay bounded even before payload validation" + ); +} + +#[test] +fn server_intake_rejects_evidence_later_than_collection_time() { + let (manifest_json, payloads) = load_bundle("multiline"); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["collectedUtc"] = Value::String("2026-07-30T00:02:58Z".to_owned()); + + assert!( + assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), + "a comparable record instant cannot be later than collection" + ); +} + +#[test] +fn server_intake_rejects_incoherent_configured_path_class() { + let (manifest_json, payloads) = load_bundle("absent-dp"); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["configuredPathProvenance"]["pathClass"] = + Value::String("nonDefault".to_owned()); + + assert!( + assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), + "a default candidate cannot claim non-default configured provenance" + ); +} + +#[test] +fn server_intake_requires_producer_host_for_declared_sources() { + let (manifest_json, payloads) = load_bundle("multiline"); + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["producerHostHandle"] = Value::Null; + + assert!( + assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), + "declared server evidence must retain its producer-host provenance" + ); +} + +#[test] +fn server_intake_exercises_committed_expected_contracts() { + for scenario in intake_scenarios() { + let expected = load_expected(&scenario); + let (manifest_json, payloads) = load_bundle(&scenario); + let assessment = assess_server_intake(&manifest_json, &payloads) + .unwrap_or_else(|error| panic!("{scenario} should be assessed: {error}")); + let actual = serde_json::to_value(&assessment).expect("assessment serializes"); + + assert_eq!( + actual["schemaVersion"], expected["pre318ExpectedVersion"], + "{scenario}: expected contract version" + ); + + let actual_artifacts = actual["artifacts"] + .as_array() + .expect("assessment artifacts are an array"); + if let Some(expected_ids) = expected.get("canonicalArtifactIds") { + let actual_ids = Value::Array( + actual_artifacts + .iter() + .map(|artifact| artifact["artifactId"].clone()) + .collect(), + ); + assert_eq!( + actual_ids, *expected_ids, + "{scenario}: canonicalArtifactIds" + ); + } + if let Some(expected_ids) = expected.get("canonicalRotationArtifactIds") { + let actual_ids = Value::Array( + actual_artifacts + .iter() + .filter(|artifact| !artifact["rotation"].is_null()) + .map(|artifact| artifact["artifactId"].clone()) + .collect(), + ); + assert_eq!( + actual_ids, *expected_ids, + "{scenario}: canonicalRotationArtifactIds" + ); + } + if let Some(expected_ids) = expected.get("retainedUnclassifiedArtifactIds") { + let actual_ids = Value::Array( + actual_artifacts + .iter() + .filter(|artifact| artifact["producerRole"] == "unclassified") + .map(|artifact| artifact["artifactId"].clone()) + .collect(), + ); + assert_eq!( + actual_ids, *expected_ids, + "{scenario}: retainedUnclassifiedArtifactIds" + ); + } + + let expected_coverage = expected["coverage"] + .as_array() + .expect("expected coverage is an array"); + let actual_coverage = actual["coverage"] + .as_array() + .expect("assessment coverage is an array"); + assert_eq!( + actual_coverage.len(), + expected_coverage.len(), + "{scenario}: coverage row count" + ); + for (actual_row, expected_row) in actual_coverage.iter().zip(expected_coverage) { + for (key, expected_value) in expected_row + .as_object() + .expect("expected coverage row is an object") + { + match key.as_str() { + "producerRole" | "workflowSubjectRole" | "sourceId" | "state" => { + assert_eq!( + &actual_row[key], expected_value, + "{scenario}: coverage {key}" + ); + } + "gap" => { + assert_eq!(expected_value, "candidate absent only"); + assert_eq!(actual_row["state"], "absent"); + let artifact = artifact_json( + &actual, + actual_row["artifactIds"][0] + .as_str() + .expect("coverage artifact ID"), + ); + assert_eq!(artifact["configuredPathState"], "defaultCandidate"); + } + "configuredRootInstances" => assert_eq!( + actual_row["artifactIds"] + .as_array() + .expect("coverage artifact IDs") + .len() as u64, + expected_value.as_u64().expect("root count is an integer") + ), + "truncated" => { + assert_eq!(expected_value, true); + let artifact = artifact_json( + &actual, + actual_row["artifactIds"][0] + .as_str() + .expect("coverage artifact ID"), + ); + assert_eq!(artifact["truncated"], true); + } + "requiredness" => { + assert_eq!(expected_value, "optionalSupplemental"); + assert!(actual["nextArtifactRequests"] + .as_array() + .expect("requests are an array") + .is_empty()); + } + other => panic!("{scenario}: unhandled coverage key {other}"), + } + } + } + + if let Some(expected_provenance) = expected.get("artifactProvenance") { + for expected_artifact in expected_provenance + .as_array() + .expect("artifact provenance is an array") + { + let artifact_id = expected_artifact["artifactId"] + .as_str() + .expect("expected artifactId is a string"); + let actual_artifact = artifact_json(&actual, artifact_id); + for (expected_key, expected_value) in expected_artifact + .as_object() + .expect("expected provenance is an object") + { + let actual_value = match expected_key.as_str() { + "artifactId" => &actual_artifact["artifactId"], + "encoding" => &actual_artifact["captureProvenance"]["encoding"], + "byteLimit" => &actual_artifact["captureProvenance"]["byteLimit"], + "limitApplied" => &actual_artifact["captureProvenance"]["limitApplied"], + "bytesCopied" => &actual_artifact["bytesCopied"], + "relativePath" => &actual_artifact["relativePath"], + "fragmentComplete" => &actual_artifact["fragmentComplete"], + "sha256" => &actual_artifact["contentSha256"], + other => panic!("{scenario}: unhandled provenance key {other}"), + }; + if expected_key != "artifactId" { + assert_eq!( + actual_value, expected_value, + "{scenario}: {artifact_id} {expected_key}" + ); + } + } + } + } + + if let Some(expected_evidence) = expected.get("evidence") { + for expected_row in expected_evidence + .as_array() + .expect("expected evidence is an array") + { + let artifact_id = expected_row["artifactId"] + .as_str() + .expect("expected evidence artifactId is a string"); + let records = actual["evidence"] + .as_array() + .expect("assessment evidence is an array") + .iter() + .filter(|row| row["reference"]["artifactId"] == artifact_id) + .collect::>(); + assert_eq!( + records.len() as u64, + expected_row["logicalRecordCount"] + .as_u64() + .expect("logicalRecordCount is an integer"), + "{scenario}: logical record count" + ); + let line_range = &expected_row["lineRange"]; + let first = records.first().unwrap_or_else(|| { + panic!("{scenario}: {artifact_id} has no evidence record for lineRange") + }); + assert_eq!( + first["reference"]["lineStart"], line_range["start"], + "{scenario}: {artifact_id} line start" + ); + assert_eq!( + first["reference"]["lineEnd"], line_range["end"], + "{scenario}: {artifact_id} line end" + ); + let keys = expected_row + .as_object() + .expect("expected evidence row is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + keys, + BTreeSet::from(["artifactId", "lineRange", "logicalRecordCount"]), + "{scenario}: every evidence expectation is asserted" + ); + } + } + + if let Some(expected_path) = expected.get("configuredPathProvenance") { + let artifact_id = expected["artifactId"] + .as_str() + .expect("configured-path expected artifactId is a string"); + let actual_artifact = artifact_json(&actual, artifact_id); + assert_eq!( + actual_artifact["configuredPathState"], + expected_path["state"] + ); + assert_eq!( + actual_artifact["configuredPathClass"], + expected_path["pathClass"] + ); + assert_eq!( + actual_artifact["pathFingerprint"], + expected_path["pathFingerprint"] + ); + } + + if let Some(expected_roles) = expected.get("rolesObserved") { + assert_eq!(actual["topology"]["rolesObserved"], *expected_roles); + } + + assert_remaining_expected_contracts( + &scenario, + &expected, + &manifest_json, + &payloads, + &actual, + ); + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake_fixture_contract.rs new file mode 100644 index 000000000..03d4c9c76 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake_fixture_contract.rs @@ -0,0 +1,108 @@ +use serde_json::Value; + +fn server_intake_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/server/intake") +} + +fn server_intake_manifests() -> Vec<(String, Value)> { + let mut scenario_dirs = std::fs::read_dir(server_intake_root()) + .expect("server intake fixture root is readable") + .map(|entry| { + entry + .expect("server intake directory entry is readable") + .path() + }) + .filter(|path| path.is_dir()) + .collect::>(); + scenario_dirs.sort(); + + scenario_dirs + .into_iter() + .map(|scenario_dir| { + let scenario = scenario_dir + .file_name() + .expect("scenario directory has a name") + .to_string_lossy() + .into_owned(); + let contents = std::fs::read_to_string(scenario_dir.join("manifest.json")) + .unwrap_or_else(|error| panic!("{scenario}: manifest is readable: {error}")); + let manifest = serde_json::from_str(&contents).unwrap_or_else(|error| { + panic!("{scenario}: manifest contains valid JSON: {error}") + }); + (scenario, manifest) + }) + .collect() +} + +#[test] +fn server_intake_uses_canonical_site_and_rotation_contracts() { + let manifests = server_intake_manifests(); + assert_eq!(manifests.len(), 12, "server intake scenario matrix changed"); + + let mut failures = Vec::new(); + for (scenario, manifest) in &manifests { + let site_code = manifest["topology"]["siteCode"] + .as_str() + .expect("server intake topology has a site code"); + if site_code.len() != 3 + || !site_code + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + failures.push(format!( + "{scenario}: siteCode must match ^[A-Z0-9]{{3}}$, got {site_code}" + )); + } + } + + let rotations = manifests + .iter() + .find(|(scenario, _)| scenario == "rotations") + .map(|(_, manifest)| manifest) + .expect("server intake has a rotations scenario"); + let rollover = rotations["artifacts"] + .as_array() + .expect("rotation artifacts are an array") + .iter() + .find(|artifact| artifact["rotation"]["kind"] == "lo_") + .expect("rotation corpus has a .lo_ artifact"); + + let basename = rollover["originalBasename"] + .as_str() + .expect("rollover artifact has an original basename"); + if basename != "MP_GetPolicy.lo_" { + failures.push(format!( + "rotations: standard ConfigMgr rollover basename must be MP_GetPolicy.lo_, got {basename}" + )); + } + + let relative_path = rollover["relativePath"] + .as_str() + .expect("captured rollover has a relative path"); + if !relative_path.ends_with("/MP_GetPolicy.lo_") { + failures.push(format!( + "rotations: rollover relativePath must end in /MP_GetPolicy.lo_, got {relative_path}" + )); + } + let fixture_path = server_intake_root().join("rotations").join(relative_path); + if !fixture_path.is_file() { + failures.push(format!( + "rotations: manifest relativePath does not resolve to a fixture: {}", + fixture_path.display() + )); + } else { + let bytes_copied = rollover["bytesCopied"] + .as_u64() + .expect("captured rollover records bytesCopied"); + let actual_bytes = std::fs::metadata(&fixture_path) + .expect("rollover fixture metadata is readable") + .len(); + if bytes_copied != actual_bytes { + failures.push(format!( + "rotations: bytesCopied {bytes_copied} does not match fixture length {actual_bytes}" + )); + } + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs new file mode 100644 index 000000000..35ff5dbed --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -0,0 +1,251 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::server::windows::{ + analyze_management_point_from_server_intake, assess_server_intake, + SccmManagementPointIntakeError, SccmServerArtifactPayload, +}; +use cmtraceopen_parser::sccm::SccmCoverageState; +use serde_json::Value; + +const FIXTURE_ROOT: &str = "tests/fixtures/sccm/server/management-point"; +const SYNTHETIC_MP_SOURCE_VERSION: &str = "5.00.TEST"; +const SYNTHETIC_MP_PROFILE_ID: &str = "mp-server-5.00.test-v1"; +const EXPECTED_MP_FIXTURE_SCENARIOS: usize = 9; +const SELECTED_MP_FIXTURE_SCENARIOS: usize = 8; +const SELECTED_MP_JOINED_PROVENANCE_ROWS: usize = 22; +const SELECTED_MP_PROFILE_VALIDATED_ROWS: usize = 20; +const OPTIONAL_IIS_SOURCE_VERSION: &str = "IIS.TEST.0000"; + +fn fixture_directory(scenario: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join(FIXTURE_ROOT) + .join(scenario) +} + +fn canonical_intake() -> cmtraceopen_parser::sccm::server::windows::SccmServerIntakeAssessment { + let directory = fixture_directory("canonical-intake-policy-scope"); + let manifest_json = fs::read_to_string(directory.join("manifest.json")) + .expect("canonical MP fixture manifest must be readable"); + let manifest: Value = serde_json::from_str(&manifest_json) + .expect("canonical MP fixture manifest must be valid JSON"); + let payloads = manifest["artifacts"] + .as_array() + .expect("canonical MP fixture artifacts") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"] + .as_str() + .expect("canonical MP artifact ID") + .to_owned(), + bytes: fs::read(directory.join(relative_path)) + .expect("canonical MP fixture payload must be readable"), + }) + }) + .collect::>(); + assess_server_intake(&manifest_json, &payloads) + .expect("canonical MP fixture must satisfy server intake") +} + +#[test] +fn canonical_intake_adapter_derives_assessed_mp_evidence_and_fails_closed() { + let assessment = canonical_intake(); + let analysis = analyze_management_point_from_server_intake(&assessment) + .expect("complete canonical MP source must enter the reducer"); + + assert!(analysis.transactions.is_empty()); + assert!(!analysis.cross_side_correlation_performed); + assert_eq!(analysis.source_local_observations.len(), 1); + assert!(analysis.source_local_observations[0] + .evidence + .iter() + .all(|reference| reference.artifact_id == "mp-policy-current")); + + let mut capped = assessment; + capped.artifacts[0].state = SccmCoverageState::Capped; + capped.artifacts[0].truncated = Some(true); + capped.artifacts[0].fragment_complete = Some(false); + assert!(matches!( + analyze_management_point_from_server_intake(&capped), + Err(SccmManagementPointIntakeError::SourceMismatch { artifact_id }) + if artifact_id == "management-point-intake-projection" + )); +} + +#[test] +fn selected_management_point_profile_prefixes_admit_exact_synthetic_versions() { + let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_ROOT); + let mut expected_scenarios = 0; + let mut selected_scenarios = 0; + let mut joined_provenance_rows = 0; + let mut profile_validated_rows = 0; + let mut optional_iis_version_verified = false; + + for entry in fs::read_dir(&fixture_root).expect("MP fixture root must be readable") { + let scenario_directory = entry + .expect("MP fixture directory entry must be readable") + .path(); + if !scenario_directory.is_dir() { + continue; + } + let expected_path = scenario_directory.join("expected.json"); + if !expected_path.is_file() { + continue; + } + expected_scenarios += 1; + + let expected: Value = serde_json::from_str( + &fs::read_to_string(expected_path).expect("MP expected fixture must be readable"), + ) + .expect("MP expected fixture must be valid JSON"); + let profile = &expected["extractionProfile"]; + let selected = matches!( + profile["selectionState"].as_str(), + Some("selected" | "selectedNoCompatibleTransaction") + ); + if !selected { + continue; + } + selected_scenarios += 1; + + let prefix = profile["sourceVersionPrefix"] + .as_str() + .filter(|prefix| !prefix.is_empty()) + .expect("selected MP profile must declare a nonempty source version prefix"); + assert_eq!( + profile["profileId"].as_str(), + Some(SYNTHETIC_MP_PROFILE_ID), + "{}: selected fixture must retain the synthetic MP profile", + scenario_directory.display() + ); + let validated_families = profile["validatedArtifactFamilies"] + .as_array() + .expect("selected MP profile must declare validated artifact families"); + let validated_role = profile["validatedRole"] + .as_str() + .expect("selected MP profile must declare a validated role"); + assert_eq!( + prefix, + SYNTHETIC_MP_SOURCE_VERSION, + "{}: selected synthetic profile must retain its exact source version prefix", + scenario_directory.display() + ); + + let expected_artifacts = expected["artifactProvenance"] + .as_array() + .expect("MP expected artifact provenance must be an array"); + assert!( + !expected_artifacts.is_empty(), + "{}: selected MP fixture must retain artifact provenance", + scenario_directory.display() + ); + + let manifest: Value = serde_json::from_str( + &fs::read_to_string(scenario_directory.join("manifest.json")) + .expect("MP manifest fixture must be readable"), + ) + .expect("MP manifest fixture must be valid JSON"); + let manifest_artifacts = manifest["artifacts"] + .as_array() + .expect("MP manifest artifacts must be an array"); + for expected_artifact in expected_artifacts { + let artifact_id = expected_artifact["artifactId"] + .as_str() + .expect("MP expected artifact ID must be a string"); + let source_version = expected_artifact["sourceVersion"] + .as_str() + .expect("selected MP artifact provenance must declare a source version"); + let manifest_artifact = manifest_artifacts + .iter() + .find(|artifact| artifact["artifactId"] == artifact_id) + .expect("MP expected artifact must exist in its manifest"); + assert_eq!( + manifest_artifact["sourceVersion"].as_str(), + Some(source_version), + "{}: {artifact_id} manifest provenance must exactly match expected source version", + scenario_directory.display() + ); + joined_provenance_rows += 1; + + let catalog_entry_id = manifest_artifact["designOnlyCatalog"]["entryId"] + .as_str() + .expect("selected MP manifest artifact must declare its catalog entry"); + let profile_validated = expected_artifact["role"].as_str() == Some(validated_role) + && validated_families + .iter() + .any(|family| family.as_str() == Some(catalog_entry_id)); + if profile_validated { + assert!( + source_version.starts_with(prefix), + "{}: {artifact_id} source version {source_version:?} must match profile prefix {prefix:?}", + scenario_directory.display() + ); + assert_eq!( + source_version, + SYNTHETIC_MP_SOURCE_VERSION, + "{}: {artifact_id} must retain the exact admitted synthetic ConfigMgr version", + scenario_directory.display() + ); + profile_validated_rows += 1; + } + + if artifact_id == "mp-iis-control-current" { + assert!( + !profile_validated, + "site-server mpcontrol provenance must not enter the Management Point profile" + ); + } + if artifact_id == "mp-iis-optional-skipped" { + assert!( + !profile_validated, + "optional IIS provenance must stay outside the Management Point profile" + ); + assert_eq!(source_version, OPTIONAL_IIS_SOURCE_VERSION); + assert_eq!( + manifest_artifact["sourceVersion"].as_str(), + Some(OPTIONAL_IIS_SOURCE_VERSION), + "optional IIS manifest provenance must retain its IIS-specific version" + ); + optional_iis_version_verified = true; + } + } + } + + assert_eq!( + expected_scenarios, EXPECTED_MP_FIXTURE_SCENARIOS, + "MP fixture scenario cardinality drifted" + ); + assert_eq!( + selected_scenarios, SELECTED_MP_FIXTURE_SCENARIOS, + "selected MP fixture scenario cardinality drifted" + ); + assert_eq!( + joined_provenance_rows, SELECTED_MP_JOINED_PROVENANCE_ROWS, + "selected MP joined provenance cardinality drifted" + ); + assert_eq!( + profile_validated_rows, SELECTED_MP_PROFILE_VALIDATED_ROWS, + "selected MP profile-validated provenance cardinality drifted" + ); + assert!( + optional_iis_version_verified, + "optional IIS provenance regression assertion did not run" + ); +} + +#[test] +#[deny(unreachable_patterns)] +fn public_management_point_intake_errors_allow_future_variants() { + let category = match SccmManagementPointIntakeError::TopologyMismatch { + SccmManagementPointIntakeError::TopologyMismatch => "topology", + SccmManagementPointIntakeError::RoleMismatch { .. } => "role", + SccmManagementPointIntakeError::ProfileMismatch { .. } => "profile", + SccmManagementPointIntakeError::SourceMismatch { .. } => "source", + SccmManagementPointIntakeError::IncompleteSource { .. } => "incomplete", + _ => "future", + }; + + assert_eq!(category, "topology"); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs b/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs new file mode 100644 index 000000000..976f5c85e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs @@ -0,0 +1,542 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::server::windows::{ + analyze_provider_admin_service, assess_server_intake, ProviderAdminServiceAnalysis, + ProviderAdminServiceClassification, ProviderAdminServiceDisposition, ProviderAdminServiceLayer, + ProviderAdminServicePhase, ProviderAdminServiceSourceLocalKind, ProviderAdminServiceState, + ProviderAdminServiceSupportState, ProviderAdminServiceTimestampOrdering, + SccmServerArtifactPayload, SccmServerIntakeAssessment, SccmServerIntakeError, +}; +use cmtraceopen_parser::sccm::{SccmCoverageState, SccmKeyConfidence, SccmRole}; +use serde_json::{json, Value}; + +const SCENARIOS: [&str; 20] = [ + "admin-service-access-denied", + "admin-service-auth-failure", + "admin-service-backend-failure", + "admin-service-parse-failed", + "admin-service-skipped", + "admin-service-success", + "blocked-deferred", + "contradictory-evidence", + "iis-supplemental", + "incomplete", + "privacy-redaction", + "provider-authz-denied", + "provider-query-failure", + "provider-retry", + "provider-source-absent", + "provider-source-capped", + "provider-source-unsupported", + "provider-success", + "provider-timeout", + "rotation-boundary", +]; + +fn corpus_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/provider_and_admin_service") +} + +fn load_manifest_and_payloads(scenario: &str) -> (Value, Vec) { + let scenario_root = corpus_root().join(scenario); + let manifest_json = + fs::read_to_string(scenario_root.join("manifest.json")).expect("fixture manifest"); + let manifest: Value = serde_json::from_str(&manifest_json).expect("valid fixture manifest"); + let payloads = manifest["artifacts"] + .as_array() + .expect("artifact array") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"] + .as_str() + .expect("artifact id") + .to_owned(), + bytes: fs::read(scenario_root.join(Path::new(relative_path))) + .expect("fixture payload"), + }) + }) + .collect(); + (manifest, payloads) +} + +fn assess(scenario: &str) -> SccmServerIntakeAssessment { + let (manifest, payloads) = load_manifest_and_payloads(scenario); + assess_server_intake(&manifest.to_string(), &payloads) + .unwrap_or_else(|error| panic!("{scenario}: canonical fixture intake: {error:?}")) +} + +fn assess_parts( + manifest: &Value, + payloads: &[SccmServerArtifactPayload], +) -> Result { + assess_server_intake(&manifest.to_string(), payloads) +} + +fn analyze(scenario: &str) -> ProviderAdminServiceAnalysis { + analyze_provider_admin_service(&assess(scenario)) +} + +fn make_provider_host_two(artifact: &mut Value, artifact_id: &str) { + artifact["artifactId"] = json!(artifact_id); + artifact["producerHostHandle"] = json!("synthetic:host:provider-02"); + let basename = artifact["originalBasename"] + .as_str() + .expect("provider basename"); + artifact["relativePath"] = json!(format!( + "evidence/sccm/server/provider/server-provider/subject-provider/root-aaaaaaaa/current/{basename}" + )); +} + +fn expected(scenario: &str) -> Value { + serde_json::from_str( + &fs::read_to_string(corpus_root().join(scenario).join("expected.json")) + .expect("expected fixture contract"), + ) + .expect("valid expected fixture contract") +} + +fn assert_exact_oracle(scenario: &str, actual: &Value, oracle: &Value) { + assert_eq!( + actual, oracle, + "{scenario}: complete public contract drifted" + ); +} + +#[test] +fn all_provider_and_admin_service_fixtures_enter_through_canonical_intake() { + for scenario in SCENARIOS { + let analysis = analyze(scenario); + assert_eq!(analysis.workflow, "providerAndAdminService", "{scenario}"); + assert_eq!( + analysis.support_state, + ProviderAdminServiceSupportState::SyntheticProfileOnly, + "{scenario}" + ); + assert!(!analysis.coverage.is_empty(), "{scenario}"); + assert!(analysis + .profiles + .iter() + .all(|profile| profile.limitation.contains("Synthetic fixtures only"))); + assert!(analysis.cross_side_causal_claims.is_empty(), "{scenario}"); + } +} + +#[test] +fn complete_fixture_matrix_runs_through_the_production_analyzer() { + for scenario in SCENARIOS { + let analysis = analyze(scenario); + let expected_contract = expected(scenario); + let public = serde_json::to_value(&analysis).unwrap_or_else(|error| { + panic!("{scenario}: shared review contract must serialize: {error}") + }); + assert_exact_oracle(scenario, &public, &expected_contract); + } +} + +#[test] +fn exact_oracle_gate_detects_mutation_of_every_material_public_surface() { + let mutations = [ + ("provider-success", "/coverage/0/producerRole"), + ("provider-success", "/coverage/0/producerHostHandle"), + ("provider-success", "/coverage/0/workflowSubjectHandle"), + ("provider-success", "/coverage/0/sourceVersion"), + ( + "provider-success", + "/profiles/0/extractionProfile/profileId", + ), + ("provider-success", "/transactions/0/transactionId"), + ("provider-success", "/transactions/0/key/requestHandle"), + ("provider-success", "/transactions/0/key/operationHandle"), + ("provider-success", "/transactions/0/key/confidence"), + ( + "provider-success", + "/transactions/0/key/extractionProfile/profileId", + ), + ( + "provider-success", + "/transactions/0/observations/0/observationId", + ), + ( + "provider-success", + "/transactions/0/observations/0/evidence/0/entryId", + ), + ("blocked-deferred", "/transactions/0/coverageGapArtifactIds"), + ( + "blocked-deferred", + "/transactions/0/nextArtifactRequests/0/request/reason", + ), + ( + "blocked-deferred", + "/transactions/0/nextArtifactRequests/0/producerHostHandle", + ), + ("blocked-deferred", "/findings/0/finding/class"), + ("blocked-deferred", "/findings/0/finding/severity"), + ("blocked-deferred", "/findings/0/finding/evidence/0/entryId"), + ( + "provider-source-capped", + "/findings/0/finding/coverageGaps/0/artifactId", + ), + ( + "provider-source-capped", + "/artifactRequests/0/workflowSubjectHandle", + ), + ( + "rotation-boundary", + "/sourceLocalObservations/0/artifactIds", + ), + ]; + for (scenario, pointer) in mutations { + let oracle = expected(scenario); + let actual = serde_json::to_value(analyze(scenario)).expect("analysis serializes"); + assert_exact_oracle(scenario, &actual, &oracle); + let mut mutated = actual; + *mutated + .pointer_mut(pointer) + .unwrap_or_else(|| panic!("{scenario}: mutation pointer must exist: {pointer}")) = + json!("oracle-mutation"); + assert_ne!(mutated, oracle, "{scenario}: oracle missed {pointer}"); + } +} + +#[test] +fn phase_reduction_covers_success_failure_deferred_recovery_and_contradiction() { + let provider_success = analyze("provider-success"); + assert_eq!( + provider_success.transactions[0] + .observations + .iter() + .map(|observation| observation.phase) + .collect::>(), + vec![ + ProviderAdminServicePhase::Receive, + ProviderAdminServicePhase::AuthenticateOrAuthorize, + ProviderAdminServicePhase::ExecuteProviderOperation, + ProviderAdminServicePhase::Respond, + ProviderAdminServicePhase::RecordOutcome, + ] + ); + + let retry = analyze("provider-retry"); + assert_eq!( + retry.transactions[0] + .observations + .iter() + .filter(|observation| { + observation.phase == ProviderAdminServicePhase::ExecuteProviderOperation + }) + .map(|observation| observation.disposition) + .collect::>(), + vec![ + ProviderAdminServiceDisposition::RetryableFailure, + ProviderAdminServiceDisposition::Succeeded, + ] + ); + assert_eq!( + retry.transactions[0].last_successful_phase, + Some(ProviderAdminServicePhase::RecordOutcome) + ); + assert_eq!( + retry.transactions[0].state, + ProviderAdminServiceState::Recovered + ); + assert_eq!( + retry.transactions[0].classification, + ProviderAdminServiceClassification::Recovered + ); + + let contradiction = analyze("contradictory-evidence"); + assert!(contradiction.transactions[0].terminal_evidence); + assert_eq!( + contradiction.transactions[0].state, + ProviderAdminServiceState::Contradictory + ); + assert_eq!( + contradiction.transactions[0].classification, + ProviderAdminServiceClassification::ContradictoryEvidence + ); + assert!(!contradiction.transactions[0].correlation_eligible); + + let blocked = analyze("blocked-deferred"); + assert_eq!( + blocked.transactions[0].state, + ProviderAdminServiceState::BlockedOrDeferred + ); + assert_eq!( + blocked.transactions[0].last_successful_phase, + Some(ProviderAdminServicePhase::AuthenticateOrAuthorize) + ); +} + +#[test] +fn one_artifact_with_two_registered_low_confidence_keys_produces_two_transactions() { + let (mut manifest, mut payloads) = load_manifest_and_payloads("provider-success"); + let original = String::from_utf8(payloads[0].bytes.clone()).expect("UTF-8 fixture"); + let peer = original + .replace( + "11111111-1111-1111-1111-111111111111", + "99999999-9999-9999-9999-999999999999", + ) + .replace( + "safe-operation-read-device", + "safe-operation-read-device-peer", + ); + payloads[0].bytes.extend_from_slice(peer.as_bytes()); + manifest["artifacts"][0]["bytesCopied"] = json!(payloads[0].bytes.len()); + + let analysis = analyze_provider_admin_service( + &assess_parts(&manifest, &payloads).expect("multi-request canonical intake"), + ); + assert_eq!(analysis.transactions.len(), 2); + assert_ne!( + analysis.transactions[0].key.request_handle, + analysis.transactions[1].key.request_handle + ); + assert!(analysis + .transactions + .iter() + .all(|transaction| transaction.state == ProviderAdminServiceState::Succeeded)); + assert!(analysis.transactions.iter().all(|transaction| { + transaction.key.confidence == SccmKeyConfidence::Low && !transaction.correlation_eligible + })); +} + +#[test] +fn timestamp_ordering_is_provenance_driven_and_valid_input_order_is_irrelevant() { + let invalid = analyze("provider-timeout"); + assert_eq!(invalid.transactions.len(), 1); + assert_eq!( + invalid.transactions[0].timestamp_ordering, + ProviderAdminServiceTimestampOrdering::Unusable + ); + assert_eq!( + invalid.transactions[0].state, + ProviderAdminServiceState::Incomplete + ); + assert!(!invalid.transactions[0].correlation_eligible); + + let baseline = analyze("provider-success"); + let (mut manifest, mut payloads) = load_manifest_and_payloads("provider-success"); + let content = String::from_utf8(payloads[0].bytes.clone()).expect("UTF-8 fixture"); + let reversed = content.lines().rev().collect::>().join("\n") + "\n"; + payloads[0].bytes = reversed.into_bytes(); + manifest["artifacts"][0]["bytesCopied"] = json!(payloads[0].bytes.len()); + let reordered = analyze_provider_admin_service( + &assess_parts(&manifest, &payloads).expect("reordered canonical intake"), + ); + assert_eq!( + reordered.transactions[0].state, + baseline.transactions[0].state + ); + assert_eq!( + reordered.transactions[0] + .observations + .iter() + .map(|observation| observation.phase) + .collect::>(), + baseline.transactions[0] + .observations + .iter() + .map(|observation| observation.phase) + .collect::>() + ); +} + +#[test] +fn coverage_gaps_are_scoped_to_the_exact_topology_subject() { + let (mut manifest, mut payloads) = load_manifest_and_payloads("provider-success"); + let (gap_manifest, gap_payloads) = load_manifest_and_payloads("provider-source-capped"); + let mut gap = gap_manifest["artifacts"][0].clone(); + gap["originalBasename"] = json!("Smsprov.lo_"); + gap["rotation"]["kind"] = json!("lo_"); + gap["relativePath"] = + json!("evidence/sccm/server/provider/server-provider/subject-provider/lo_/Smsprov.lo_"); + manifest["artifacts"] + .as_array_mut() + .expect("artifact array") + .push(gap); + payloads.extend(gap_payloads); + + let analysis = analyze_provider_admin_service( + &assess_parts(&manifest, &payloads).expect("mixed-coverage canonical intake"), + ); + let transaction = &analysis.transactions[0]; + assert_eq!(transaction.state, ProviderAdminServiceState::Incomplete); + assert_eq!( + transaction.coverage_gap_artifact_ids, + vec!["coverage-provider-capped"] + ); + assert!(!transaction.next_artifact_requests.is_empty()); + assert!(!transaction.correlation_eligible); +} + +#[test] +fn coverage_gaps_are_scoped_to_the_producer_host_as_well_as_the_subject() { + let (mut manifest, mut payloads) = load_manifest_and_payloads("provider-success"); + let (gap_manifest, gap_payloads) = load_manifest_and_payloads("provider-source-capped"); + let mut gap = gap_manifest["artifacts"][0].clone(); + make_provider_host_two(&mut gap, "coverage-provider-capped"); + gap["originalBasename"] = json!("Smsprov.lo_"); + gap["rotation"]["kind"] = json!("lo_"); + gap["relativePath"] = json!( + "evidence/sccm/server/provider/server-provider/subject-provider/root-aaaaaaaa/lo_/Smsprov.lo_" + ); + let gap_id = gap["artifactId"].as_str().expect("gap id").to_owned(); + manifest["artifacts"] + .as_array_mut() + .expect("artifact array") + .push(gap); + payloads.extend(gap_payloads.into_iter().map(|mut payload| { + payload.manifest_artifact_id = gap_id.clone(); + payload + })); + + let analysis = analyze_provider_admin_service( + &assess_parts(&manifest, &payloads).expect("cross-host coverage intake"), + ); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!( + analysis.transactions[0].state, + ProviderAdminServiceState::Succeeded + ); + assert!(analysis.transactions[0] + .coverage_gap_artifact_ids + .is_empty()); + assert!(analysis.transactions[0].next_artifact_requests.is_empty()); + assert_eq!(analysis.artifact_requests.len(), 1); + assert_eq!( + analysis.artifact_requests[0].producer_host_handle, + "synthetic:host:provider-02" + ); +} + +#[test] +fn transaction_identity_includes_producer_host() { + let (mut manifest, mut payloads) = load_manifest_and_payloads("provider-success"); + let mut second = manifest["artifacts"][0].clone(); + make_provider_host_two(&mut second, "provider-retry-current"); + let second_id = second["artifactId"].as_str().expect("second id").to_owned(); + manifest["artifacts"] + .as_array_mut() + .expect("artifact array") + .push(second); + let mut second_payload = payloads[0].clone(); + second_payload.manifest_artifact_id = second_id; + payloads.push(second_payload); + + let analysis = analyze_provider_admin_service( + &assess_parts(&manifest, &payloads).expect("two-host canonical intake"), + ); + assert_eq!(analysis.transactions.len(), 2); + assert_ne!( + analysis.transactions[0].transaction_id, + analysis.transactions[1].transaction_id + ); + assert_ne!( + analysis.transactions[0].key.producer_host_handle, + analysis.transactions[1].key.producer_host_handle + ); +} + +#[test] +fn independent_provider_and_admin_service_gaps_keep_two_scoped_requests() { + let (mut provider, _) = load_manifest_and_payloads("provider-source-absent"); + let (admin, _) = load_manifest_and_payloads("admin-service-access-denied"); + provider["topology"]["rolesObserved"] = json!(["provider", "adminService"]); + provider["artifacts"] + .as_array_mut() + .expect("provider artifacts") + .push(admin["artifacts"][0].clone()); + + let analysis = analyze_provider_admin_service( + &assess_parts(&provider, &[]).expect("two-layer coverage intake"), + ); + assert_eq!(analysis.artifact_requests.len(), 2); + assert!(analysis.artifact_requests.iter().any(|request| { + request.layer == ProviderAdminServiceLayer::Provider + && request.producer_role == SccmRole::Provider + && request.request.logical_id == "smsprov" + })); + assert!(analysis.artifact_requests.iter().any(|request| { + request.layer == ProviderAdminServiceLayer::AdminService + && request.producer_role == SccmRole::AdminService + && request.request.logical_id == "adminService" + })); +} + +#[test] +fn forged_or_unregistered_profile_never_creates_a_transaction() { + let (mut manifest, mut payloads) = load_manifest_and_payloads("provider-success"); + let forged = String::from_utf8(payloads[0].bytes.clone()) + .expect("UTF-8 fixture") + .replace( + "ProfileId=provider-server-5.00.test-v1", + "ProfileId=provider-server-5.00.test-v1-forged", + ); + payloads[0].bytes = forged.into_bytes(); + manifest["artifacts"][0]["bytesCopied"] = json!(payloads[0].bytes.len()); + let analysis = analyze_provider_admin_service( + &assess_parts(&manifest, &payloads).expect("forged profile remains valid raw intake"), + ); + assert!(analysis.transactions.is_empty()); + assert!(analysis.findings.is_empty()); + assert!(!serde_json::to_string(&analysis) + .expect("analysis serializes") + .contains("\"confidence\":\"exact\"")); +} + +#[test] +fn public_projection_is_privacy_safe_and_admin_service_has_its_own_role() { + let assessment = assess("privacy-redaction"); + let analysis = analyze_provider_admin_service(&assessment); + assert!(analysis + .transactions + .iter() + .any(|transaction| transaction.producer_role == SccmRole::AdminService)); + assert!(analysis + .source_local_observations + .iter() + .all(|observation| { + observation.kind == ProviderAdminServiceSourceLocalKind::PrivacyRedacted + && !observation.correlation_eligible + })); + let public = serde_json::to_string(&analysis).expect("privacy-safe report serializes"); + for private in [ + "99999999-9999-9999-9999-999999999999", + "safe-operation-provider-privacy", + "safe-operation-admin-privacy", + "synthetic.user@example.invalid", + "Bearer", + "SELECT", + "provider-local", + "admin-service-lab", + ] { + assert!(!public.contains(private), "private shape leaked: {private}"); + } + assert!(public.contains("cmtraceopen.request.sha256.v1:")); + assert!(public.contains("cmtraceopen.operation.sha256.v1:")); +} + +#[test] +fn canonical_intake_seals_role_and_authority_before_analysis() { + let (mut manifest, payloads) = load_manifest_and_payloads("admin-service-success"); + manifest["topology"]["rolesObserved"] = json!(["adminService", "provider"]); + manifest["artifacts"][0]["producerRole"] = json!("provider"); + assert_eq!( + assess_parts(&manifest, &payloads), + Err(SccmServerIntakeError::InvalidArtifact) + ); + + let mut assessment = assess("provider-success"); + assessment.coverage[0].state = SccmCoverageState::Capped; + let analysis = analyze_provider_admin_service(&assessment); + assert_eq!( + analysis.support_state, + ProviderAdminServiceSupportState::IntakeAuthorityInvalid + ); + assert!(analysis.transactions.is_empty()); + assert!(analysis.coverage.is_empty()); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service_fixture_contract.rs new file mode 100644 index 000000000..7f9afa473 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service_fixture_contract.rs @@ -0,0 +1,457 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::{classify_artifact_name, SccmArtifactFamily, SccmRole}; +use serde_json::{Map, Value}; + +const SCENARIOS: [&str; 20] = [ + "admin-service-access-denied", + "admin-service-auth-failure", + "admin-service-backend-failure", + "admin-service-parse-failed", + "admin-service-skipped", + "admin-service-success", + "blocked-deferred", + "contradictory-evidence", + "iis-supplemental", + "incomplete", + "privacy-redaction", + "provider-authz-denied", + "provider-query-failure", + "provider-retry", + "provider-source-absent", + "provider-source-capped", + "provider-source-unsupported", + "provider-success", + "provider-timeout", + "rotation-boundary", +]; + +fn corpus_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/provider_and_admin_service") +} + +fn read_json(scenario: &str, filename: &str) -> Value { + let path = corpus_root().join(scenario).join(filename); + serde_json::from_str(&fs::read_to_string(&path).expect("fixture file is readable")) + .unwrap_or_else(|error| panic!("{} is valid JSON: {error}", path.display())) +} + +fn actual_scenarios() -> BTreeSet { + fs::read_dir(corpus_root()) + .expect("corpus root is readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then(|| { + path.file_name() + .expect("scenario directory name") + .to_string_lossy() + .into_owned() + }) + }) + .collect() +} + +fn assert_allowed_keys(object: &Map, allowed: &[&str], context: &str) { + let allowed = allowed.iter().copied().collect::>(); + for key in object.keys() { + assert!( + allowed.contains(key.as_str()), + "{context}: unknown field {key}" + ); + } +} + +fn physical_state(state: &str) -> bool { + matches!(state, "captured" | "capped" | "parseFailed") +} + +fn private_payload_tokens(scenario: &str, manifest: &Value) -> BTreeSet { + let mut tokens = BTreeSet::new(); + for artifact in manifest["artifacts"].as_array().expect("artifact array") { + let Some(relative) = artifact["relativePath"].as_str() else { + continue; + }; + let content = fs::read_to_string(corpus_root().join(scenario).join(relative)) + .expect("synthetic CCM payload is UTF-8"); + for line in content.lines() { + let Some(message) = line + .strip_prefix("").map(|pair| pair.0)) + else { + continue; + }; + for field in message.split(';').map(str::trim) { + let Some((name, value)) = field.split_once('=') else { + continue; + }; + if matches!( + name, + "RequestId" + | "OperationHandle" + | "EndpointId" + | "CallerHandle" + | "Authorization" + | "QueryHandle" + ) { + tokens.insert(value.to_owned()); + } + } + } + } + tokens +} + +#[test] +fn corpus_has_the_exact_reviewed_scenario_matrix() { + assert_eq!( + actual_scenarios(), + SCENARIOS.into_iter().map(str::to_owned).collect() + ); +} + +#[test] +fn manifests_use_closed_schemas_and_exact_topology_authority() { + let mut corpus_artifact_ids = BTreeSet::new(); + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json"); + let top = manifest.as_object().expect("manifest object"); + assert_eq!( + top.keys().map(String::as_str).collect::>(), + [ + "artifacts", + "bundleRole", + "privacy", + "proposalOnly", + "sccmManifestVersion", + "syntheticFixture", + "topology", + ] + .into_iter() + .collect(), + "{scenario}: exact top-level fields" + ); + assert_eq!(manifest["sccmManifestVersion"], 1, "{scenario}"); + assert_eq!(manifest["syntheticFixture"], true, "{scenario}"); + assert_eq!(manifest["proposalOnly"], true, "{scenario}"); + assert_eq!(manifest["bundleRole"], "server", "{scenario}"); + assert_eq!(manifest["privacy"]["synthetic"], true, "{scenario}"); + assert_eq!(manifest["privacy"]["rawPaths"], "redacted", "{scenario}"); + assert_eq!( + manifest["privacy"] + .as_object() + .expect("privacy object") + .keys() + .map(String::as_str) + .collect::>(), + ["rawPaths", "synthetic"].into_iter().collect(), + "{scenario}: exact privacy fields" + ); + let topology = manifest["topology"].as_object().expect("topology object"); + assert_eq!( + topology.keys().map(String::as_str).collect::>(), + ["captureHost", "rolesObserved", "siteCode"] + .into_iter() + .collect(), + "{scenario}: exact topology fields" + ); + assert_eq!(topology["captureHost"], "LAB-CM01", "{scenario}"); + assert_eq!(topology["siteCode"], "LAB", "{scenario}"); + let roles = topology["rolesObserved"].as_array().expect("roles array"); + assert!(!roles.is_empty(), "{scenario}"); + assert!(roles + .iter() + .all(|role| matches!(role.as_str(), Some("provider" | "adminService")))); + + let mut scenario_paths = BTreeSet::new(); + for artifact in manifest["artifacts"].as_array().expect("artifact array") { + let object = artifact.as_object().expect("artifact object"); + assert_allowed_keys( + object, + &[ + "artifactId", + "bytesCopied", + "captureState", + "collectedUtc", + "collectionDetail", + "collectionLimit", + "configuredPathProvenance", + "encoding", + "fragmentComplete", + "originalBasename", + "originalPath", + "producerHostHandle", + "producerRole", + "relativePath", + "rotation", + "skipReason", + "sourceId", + "sourceKind", + "sourceVersion", + "truncated", + "unsupportedReason", + "workflowSubject", + ], + scenario, + ); + for required in [ + "artifactId", + "producerRole", + "producerHostHandle", + "workflowSubject", + "sourceId", + "sourceKind", + "originalPath", + "originalBasename", + "configuredPathProvenance", + "rotation", + "captureState", + "collectedUtc", + "bytesCopied", + ] { + assert!( + object.contains_key(required), + "{scenario}: missing {required}" + ); + } + assert_allowed_keys( + artifact["workflowSubject"] + .as_object() + .expect("workflow subject"), + &["instanceHandle", "role"], + scenario, + ); + assert_allowed_keys( + artifact["configuredPathProvenance"] + .as_object() + .expect("configured path"), + &["pathFingerprint", "state"], + scenario, + ); + assert_allowed_keys( + artifact["rotation"].as_object().expect("rotation"), + &["kind", "lineageId"], + scenario, + ); + if let Some(limit) = artifact.get("collectionLimit") { + assert_allowed_keys( + limit.as_object().expect("collection limit"), + &["byteLimit", "limitApplied"], + scenario, + ); + } + + let artifact_id = artifact["artifactId"].as_str().expect("artifact id"); + assert!( + corpus_artifact_ids.insert(artifact_id.to_owned()), + "duplicate {artifact_id}" + ); + let source_id = artifact["sourceId"].as_str().expect("source id"); + let (role, host, subject, profile) = match source_id { + "server-provider" => ( + "provider", + "synthetic:host:provider-01", + "synthetic:subject:provider-01", + "provider-server-5.00.test-v1", + ), + "server-admin-service" | "server-admin-service-iis" => ( + "adminService", + "synthetic:host:admin-service-01", + "synthetic:subject:admin-service-01", + "admin-service-server-5.00.test-v1", + ), + _ => panic!("{scenario}: undeclared source {source_id}"), + }; + assert_eq!(artifact["producerRole"], role, "{scenario}"); + assert_eq!(artifact["producerHostHandle"], host, "{scenario}"); + assert_eq!(artifact["workflowSubject"]["role"], role, "{scenario}"); + assert_eq!( + artifact["workflowSubject"]["instanceHandle"], subject, + "{scenario}" + ); + let basename = artifact["originalBasename"].as_str().expect("basename"); + assert!( + matches!( + (source_id, basename), + ("server-provider", "Smsprov.log" | "Smsprov.lo_") + | ("server-admin-service", "AdminService.log") + | ("server-admin-service-iis", "u_ex_synthetic.log") + ), + "{scenario}: source/basename tuple" + ); + assert!(artifact["originalPath"] + .as_str() + .is_some_and(|path| path.starts_with("REDACTED_"))); + + let state = artifact["captureState"].as_str().expect("capture state"); + if physical_state(state) { + if scenario != "rotation-boundary" { + assert_eq!(artifact["sourceVersion"], "5.00.TEST", "{scenario}"); + } + let relative = artifact["relativePath"].as_str().expect("relative path"); + assert!(relative.starts_with("evidence/sccm/server/"), "{scenario}"); + assert!(relative.ends_with(basename), "{scenario}"); + assert!( + scenario_paths.insert(relative.to_owned()), + "{scenario}: duplicate path" + ); + let payload = corpus_root().join(scenario).join(Path::new(relative)); + let content = fs::read_to_string(&payload) + .unwrap_or_else(|error| panic!("{}: {error}", payload.display())); + assert_eq!( + artifact["bytesCopied"].as_u64(), + Some(content.len() as u64), + "{scenario}" + ); + assert!(artifact["collectionLimit"].is_object(), "{scenario}"); + if source_id != "server-admin-service-iis" + && state != "parseFailed" + && scenario != "rotation-boundary" + { + for line in content.lines() { + assert!(line.starts_with(">(); + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json"); + let expected = read_json(scenario, "expected.json"); + assert_eq!( + expected + .as_object() + .expect("oracle object") + .keys() + .map(String::as_str) + .collect::>(), + expected_keys, + "{scenario}: complete public oracle fields" + ); + assert_eq!( + expected["workflow"], "providerAndAdminService", + "{scenario}" + ); + assert_eq!( + expected["supportState"], "syntheticProfileOnly", + "{scenario}" + ); + for collection in [ + "profiles", + "coverage", + "transactions", + "findings", + "sourceLocalObservations", + "artifactRequests", + "crossSideCausalClaims", + ] { + assert!(expected[collection].is_array(), "{scenario}: {collection}"); + } + let rendered = serde_json::to_string(&expected).expect("oracle renders"); + for private in private_payload_tokens(scenario, &manifest) { + assert!( + !rendered.contains(&private), + "{scenario}: private payload token escaped: {private}" + ); + } + for transaction in expected["transactions"].as_array().expect("transactions") { + assert_eq!(transaction["key"]["confidence"], "low", "{scenario}"); + assert_eq!( + transaction["key"]["extractionProfile"]["maturity"], "experimental", + "{scenario}" + ); + assert_eq!(transaction["correlationEligible"], false, "{scenario}"); + } + for finding in expected["findings"].as_array().expect("findings") { + assert!(finding["finding"]["class"].is_string(), "{scenario}"); + assert!(finding["finding"]["severity"].is_string(), "{scenario}"); + assert!(finding["producerHostHandle"].is_string(), "{scenario}"); + assert!(finding["workflowSubjectHandle"].is_string(), "{scenario}"); + } + } +} + +#[test] +fn phase_disposition_terminal_and_rotation_edges_are_adversarially_present() { + let retry = fs::read_to_string(corpus_root().join("provider-retry/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log")) + .expect("retry fixture"); + assert!(retry.contains("Disposition=retryableFailure; Terminal=false")); + assert!(retry.contains("Disposition=succeeded; Terminal=true")); + + let contradiction = fs::read_to_string(corpus_root().join("contradictory-evidence/evidence/sccm/server/provider/server-provider/subject-provider/current/Smsprov.log")) + .expect("contradiction fixture"); + assert!(contradiction.contains("Disposition=failed; Terminal=true")); + assert!(contradiction.contains("Disposition=succeeded; Terminal=true")); + + let rotation = read_json("rotation-boundary", "manifest.json"); + let artifacts = rotation["artifacts"] + .as_array() + .expect("rotation artifacts"); + assert_eq!(artifacts.len(), 2); + assert_eq!(artifacts[0]["rotation"]["kind"], "current"); + assert_eq!(artifacts[1]["rotation"]["kind"], "lo_"); + assert_ne!(artifacts[0]["artifactId"], artifacts[1]["artifactId"]); + assert_ne!(artifacts[0]["relativePath"], artifacts[1]["relativePath"]); +} + +#[test] +fn source_catalog_distinguishes_provider_and_admin_service() { + let provider = classify_artifact_name("Smsprov.log", SccmRole::Provider); + assert!(provider.supported_for_diagnosis); + assert_eq!(provider.family, SccmArtifactFamily::Provider); + assert_eq!(provider.role, SccmRole::Provider); + + let admin = classify_artifact_name("AdminService.log", SccmRole::AdminService); + assert!(admin.supported_for_diagnosis); + assert_eq!(admin.family, SccmArtifactFamily::AdminService); + assert_eq!(admin.role, SccmRole::AdminService); + + for wrong_role in [ + SccmRole::SiteServer, + SccmRole::ManagementPoint, + SccmRole::DistributionPoint, + SccmRole::SoftwareUpdatePoint, + SccmRole::WsUs, + SccmRole::Provider, + ] { + let wrong = classify_artifact_name("AdminService.log", wrong_role); + assert!(!wrong.supported_for_diagnosis); + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs new file mode 100644 index 000000000..98081b87e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -0,0 +1,1879 @@ +use cmtraceopen_parser::sccm::server::windows::{ + analyze_site_core, assess_server_intake, SccmServerArtifactPayload, SccmServerIntakeAssessment, + SccmSiteCoreAnalysis, SccmSiteCoreArtifactRequest, SccmSiteCoreConfidence, SccmSiteCorePhase, + SccmSiteCoreState, +}; +use cmtraceopen_parser::sccm::{ + SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, SccmTimeOrderingState, + SccmUnknownRotation, +}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const HEALTHY_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const HEALTHY_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-status/current/statmgr.log" +); +const COMPONENT_FAILURE: &str = include_str!( + "fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const INBOX_BACKLOG: &str = include_str!( + "fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const STATUS_FAILURE_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const STATUS_FAILURE_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-status/current/statmgr.log" +); +const RECOVERY_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const RECOVERY_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-status/current/statmgr.log" +); +const CONTRADICTORY_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const CONTRADICTORY_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-status/current/statmgr.log" +); +const ROTATION_CURRENT_FRAGMENT: &str = include_str!( + "fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const ROTATION_LO_FRAGMENT: &str = include_str!( + "fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_" +); +const OUT_OF_ORDER_SITECOMP: &str = concat!( + "\n", + "\n", + "\n", +); +const OUT_OF_ORDER_STATUS: &str = concat!( + "\n", + "\n", +); +const SUCCESS_AFTER_FAILURE: &str = concat!( + "\n", + "\n", + "\n", +); +const DEFERRED_THEN_ACCEPTED: &str = concat!( + "\n", + "\n", + "\n", + "\n", +); +const TERMINAL_FAILURE_WITHOUT_SUCCESS: &str = + "\n"; + +#[derive(Clone)] +struct Source<'a> { + artifact_id: &'static str, + source_id: &'static str, + basename: &'static str, + path_fingerprint: &'static str, + lineage_id: &'static str, + rotation_kind: &'static str, + rotation_value: Option, + content: Option<&'a str>, + capture_state: &'static str, + configured_state: &'static str, + path_class: Option<&'static str>, + encoding: Option<&'static str>, + limit_applied: bool, + truncated: Option, + fragment_complete: Option, +} + +impl<'a> Source<'a> { + fn sitecomp(content: &'a str) -> Self { + Self { + artifact_id: "sitecomp-current", + source_id: "server-sitecomp", + basename: "sitecomp.log", + path_fingerprint: "synthetic:path:site-default", + lineage_id: "sitecomp-lab", + rotation_kind: "current", + rotation_value: None, + content: Some(content), + capture_state: "captured", + configured_state: "configured", + path_class: None, + encoding: Some("utf-8"), + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn status(content: &'a str) -> Self { + Self { + artifact_id: "z-site-status", + source_id: "server-status", + basename: "statmgr.log", + path_fingerprint: "synthetic:path:z-site", + lineage_id: "site-status-z", + rotation_kind: "current", + rotation_value: None, + content: Some(content), + capture_state: "captured", + configured_state: "configured", + path_class: None, + encoding: Some("utf-8"), + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn absent_status() -> Self { + Self { + artifact_id: "z-site-status", + source_id: "server-status", + basename: "statmgr.log", + path_fingerprint: "synthetic:path:z-site", + lineage_id: "site-status-z", + rotation_kind: "current", + rotation_value: None, + content: None, + capture_state: "absent", + configured_state: "defaultCandidate", + path_class: None, + encoding: None, + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn default_sitecomp_candidate() -> Self { + Self { + artifact_id: "b-sitecomp", + source_id: "server-sitecomp", + basename: "sitecomp.log", + path_fingerprint: "synthetic:path:a-site", + lineage_id: "sitecomp-a", + rotation_kind: "current", + rotation_value: None, + content: None, + capture_state: "absent", + configured_state: "defaultCandidate", + path_class: None, + encoding: None, + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn capped_sitecomp(content: &'a str) -> Self { + let mut source = Self::sitecomp(content); + source.capture_state = "capped"; + source.limit_applied = true; + source.truncated = Some(true); + source.fragment_complete = Some(false); + source + } + + fn sitecomp_lo_fragment(content: &'a str) -> Self { + Self { + artifact_id: "b-sitecomp", + source_id: "server-sitecomp", + basename: "sitecomp.lo_", + path_fingerprint: "synthetic:path:site-default", + lineage_id: "sitecomp-lab", + rotation_kind: "lo_", + rotation_value: None, + content: Some(content), + capture_state: "captured", + configured_state: "configured", + path_class: None, + encoding: Some("utf-8"), + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn numbered_status(content: &'a str) -> Self { + let mut source = Self::status(content); + source.basename = "statmgr.log.2"; + source.rotation_kind = "numbered"; + source.rotation_value = Some(json!(2)); + source + } + + fn relative_path(&self) -> Option { + self.content.map(|_| { + let rotation = match self.rotation_kind { + "current" => "current", + "lo_" => "lo_", + "numbered" => "numbered-2", + other => panic!("unsupported test rotation {other}"), + }; + format!( + "evidence/sccm/server/site-server/{}/{rotation}/{}", + self.source_id, self.basename + ) + }) + } + + fn manifest_artifact(&self) -> Value { + let bytes_copied = self.content.map_or(0, |content| content.len() as u64); + let collection_limit = self.content.map(|_| { + json!({ + "byteLimit": if self.limit_applied { bytes_copied } else { bytes_copied.max(4096) }, + "limitApplied": self.limit_applied, + }) + }); + json!({ + "artifactId": self.artifact_id, + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": self.source_id, + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": self.basename, + "configuredPathProvenance": { + "state": self.configured_state, + "pathClass": self.path_class, + "pathFingerprint": self.path_fingerprint, + }, + "defaultCandidateState": if self.configured_state == "defaultCandidate" { + Some("absentCandidateOnly") + } else { + None + }, + "rotation": { + "kind": self.rotation_kind, + "value": self.rotation_value, + "lineageId": self.lineage_id, + }, + "captureState": self.capture_state, + "encoding": self.encoding, + "collectionLimit": collection_limit, + "truncated": self.truncated, + "fragmentComplete": self.fragment_complete, + "collectedUtc": "2026-07-30T16:00:00Z", + "relativePath": self.relative_path(), + "bytesCopied": bytes_copied, + }) + } +} + +fn assess(sources: &[Source<'_>]) -> SccmServerIntakeAssessment { + assess_with_producer_hosts(sources, &[]) +} + +fn assess_with_producer_hosts( + sources: &[Source<'_>], + producer_hosts: &[(&str, &str)], +) -> SccmServerIntakeAssessment { + let artifacts = sources + .iter() + .map(|source| { + let mut artifact = source.manifest_artifact(); + if let Some((_, producer_host)) = producer_hosts + .iter() + .find(|(source_id, _)| *source_id == source.source_id) + { + artifact["producerHostHandle"] = Value::String((*producer_host).to_owned()); + } + artifact + }) + .collect::>(); + let manifest = json!({ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": {"synthetic": true, "rawPaths": "redacted"}, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": ["siteServer"], + }, + "artifacts": artifacts, + }); + let payloads = sources + .iter() + .filter_map(|source| { + source.content.map(|content| SccmServerArtifactPayload { + manifest_artifact_id: source.artifact_id.to_owned(), + bytes: content.as_bytes().to_vec(), + }) + }) + .collect::>(); + + assess_server_intake(&manifest.to_string(), &payloads) + .expect("site-core test manifest must pass the shared server intake") +} + +fn replace_source_artifact_id( + assessment: &mut SccmServerIntakeAssessment, + source_id: &str, + replacement: &str, +) { + let artifact = assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == source_id) + .expect("source artifact"); + let original = std::mem::replace(&mut artifact.artifact_id, replacement.to_owned()); + for coverage in &mut assessment.coverage { + for artifact_id in &mut coverage.artifact_ids { + if artifact_id == &original { + *artifact_id = replacement.to_owned(); + } + } + } + for evidence in &mut assessment.evidence { + if evidence.reference.artifact_id == original { + evidence.reference.artifact_id = replacement.to_owned(); + } + } +} + +fn replace_source_producer_host( + assessment: &mut SccmServerIntakeAssessment, + source_id: &str, + replacement: &str, +) { + let artifact = assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == source_id) + .expect("source artifact"); + artifact.producer_host_handle = Some(replacement.to_owned()); + let coverage = assessment + .coverage + .iter_mut() + .find(|coverage| coverage.artifact_ids.contains(&artifact.artifact_id)) + .expect("source coverage"); + coverage.producer_host_handle = Some(replacement.to_owned()); +} + +fn assert_bounded_request_has_specific_scope(request: &SccmSiteCoreArtifactRequest) { + assert!((1..=2).contains(&request.max_artifacts)); + assert!(!request.candidates.is_empty()); + assert!(request.candidates.len() <= request.max_artifacts); + assert!(request.candidates.iter().all(|candidate| { + !candidate.basename.trim().is_empty() && !candidate.rotation.trim().is_empty() + })); + assert!( + request + .scope + .producer_host_handle + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || request + .scope + .component_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || request + .scope + .work_item_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || request + .scope + .rotation_lineage_handle + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), + "request {} serialized an empty or unusable scope", + request.logical_name + ); +} + +fn assert_malformed_peer_source_fails_closed(analysis: &SccmSiteCoreAnalysis, malformed_id: &str) { + assert_authority_invalid_analysis(analysis); + let wire = serde_json::to_string(analysis).expect("analysis serializes"); + assert!(!wire.contains(malformed_id)); +} + +fn assert_intake_authority_mutation_fails_closed( + analysis: &SccmSiteCoreAnalysis, + intake: &SccmServerIntakeAssessment, +) { + // Once the intake seal fails, even the original canonical source values + // are no longer authority and must not survive the constant quarantine. + let source_triples = intake + .artifacts + .iter() + .map(|artifact| { + ( + artifact.artifact_id.as_str(), + artifact.source_id.as_str(), + artifact.producer_host_handle.as_deref(), + ) + }) + .collect::>(); + assert_invalid_authority_excludes_source_triples(analysis, &source_triples); +} + +fn assert_authority_invalid_analysis(analysis: &SccmSiteCoreAnalysis) { + assert!(analysis.results.is_empty()); + assert_eq!(analysis.coverage_gaps.len(), 1); + let gap = &analysis.coverage_gaps[0]; + assert_eq!(gap.artifact_id, "site-core-intake-authority"); + assert_eq!(gap.source_id, "server-site-core-intake"); + assert_eq!(gap.state, SccmCoverageState::ParseFailed); + assert_eq!(gap.reason_code, "intake-authority-invalid"); + + assert_eq!(analysis.unlinked_observations.len(), 1); + let observation = &analysis.unlinked_observations[0]; + assert_eq!( + observation.finding_class, + SccmFindingClass::InsufficientEvidence + ); + assert_eq!( + observation.coverage_gap_artifact_ids, + ["site-core-intake-authority"] + ); + assert!(observation.evidence.is_empty()); + assert!(observation.next_artifacts.is_empty()); + + assert!(analysis.findings.is_empty()); + assert!(analysis.artifact_requests.is_empty()); + assert!(!analysis.cross_side_correlation_performed); +} + +fn assert_delimiter_attached_unknown_label_fails_closed(delimiter: char) { + for (position, outcome_field) in [ + ( + "after-known", + format!("outcome=success{delimiter}unreviewed=x"), + ), + ( + "before-known", + format!("unreviewed=x{delimiter}outcome=success"), + ), + ] { + let sitecomp = HEALTHY_SITECOMP.replace("outcome=success", &outcome_field); + let status = HEALTHY_STATUS.replace("outcome=success", &outcome_field); + let assessment = assess(&[Source::sitecomp(&sitecomp), Source::status(&status)]); + let expected_observations = assessment.evidence.len(); + let analysis = analyze_site_core(&assessment); + + assert!( + analysis.results.is_empty(), + "delimiter {delimiter:?} {position} must not create a transaction" + ); + assert_eq!( + analysis.unlinked_observations.len(), + expected_observations, + "delimiter {delimiter:?} {position} must retain every rejected record" + ); + assert_eq!(analysis.findings.len(), expected_observations); + assert!(analysis.unlinked_observations.iter().all(|observation| { + observation.finding_class == SccmFindingClass::Symptom + && observation.evidence.len() == 1 + && observation.next_artifacts.len() == 1 + && observation.next_artifacts[0].candidates.len() == 1 + })); + assert!(analysis + .findings + .iter() + .all(|finding| finding.finding.class == SccmFindingClass::Symptom)); + } +} + +fn site_core_corpus_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/server/site_core") +} + +fn load_corpus_scenario(scenario: &str) -> (SccmServerIntakeAssessment, Value) { + let root = site_core_corpus_root().join(scenario); + let manifest_path = root.join("manifest.json"); + let manifest_json = fs::read_to_string(&manifest_path) + .unwrap_or_else(|error| panic!("read {}: {error}", manifest_path.display())); + let manifest: Value = serde_json::from_str(&manifest_json) + .unwrap_or_else(|error| panic!("parse {}: {error}", manifest_path.display())); + let payloads = manifest["artifacts"] + .as_array() + .expect("corpus manifest artifacts") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + let artifact_id = artifact["artifactId"].as_str().expect("corpus artifact id"); + let evidence_path = root.join(relative_path); + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact_id.to_owned(), + bytes: fs::read(&evidence_path) + .unwrap_or_else(|error| panic!("read {}: {error}", evidence_path.display())), + }) + }) + .collect::>(); + let assessment = assess_server_intake(&manifest_json, &payloads) + .unwrap_or_else(|error| panic!("assess corpus scenario {scenario}: {error}")); + let expected_path = root.join("expected.json"); + let expected = serde_json::from_str( + &fs::read_to_string(&expected_path) + .unwrap_or_else(|error| panic!("read {}: {error}", expected_path.display())), + ) + .unwrap_or_else(|error| panic!("parse {}: {error}", expected_path.display())); + (assessment, expected) +} + +#[test] +fn healthy_site_core_is_reduced_from_server_intake_without_raw_site_identity() { + let assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + let analysis = analyze_site_core(&assessment); + + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.state, SccmSiteCoreState::Healthy); + assert_eq!( + result.last_successful_phase, + Some(SccmSiteCorePhase::HealthyOrTerminal) + ); + assert_eq!(result.confidence, SccmSiteCoreConfidence::High); + assert_eq!(result.transaction_key.site_handle, "synthetic:site:lab"); + assert_eq!( + result.transaction_key.producer_host_handle, + "synthetic:host:site-01" + ); + assert!(analysis.findings.is_empty()); + + let wire = serde_json::to_string(&analysis).expect("site-core analysis serializes"); + assert!(!wire.contains("siteCode")); + assert!(!wire.contains("\"LAB\"")); + assert!(!wire.contains("/LAB/")); + assert!(!wire.contains("clientImpact")); + assert!(!analysis.cross_side_correlation_performed); +} + +#[test] +fn configured_nondefault_sources_supersede_absent_default_candidates() { + let mut sitecomp = Source::sitecomp(HEALTHY_SITECOMP); + sitecomp.path_class = Some("nonDefault"); + let mut status = Source::status(HEALTHY_STATUS); + status.path_class = Some("nonDefault"); + let assessment = assess(&[Source::default_sitecomp_candidate(), status, sitecomp]); + assert!(assessment.artifacts.iter().any(|artifact| { + artifact.configured_path_class + == Some( + cmtraceopen_parser::sccm::server::windows::SccmServerConfiguredPathClass::NonDefault, + ) + })); + + let analysis = analyze_site_core(&assessment); + assert_eq!(analysis.results.len(), 1); + assert_eq!(analysis.results[0].state, SccmSiteCoreState::Healthy); + assert_eq!(analysis.results[0].confidence, SccmSiteCoreConfidence::High); + assert!(analysis.coverage_gaps.is_empty()); + assert!(analysis.findings.is_empty()); + assert!(analysis.artifact_requests.is_empty()); +} + +#[test] +fn complete_catalogued_rotations_remain_profile_usable() { + let assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::numbered_status(HEALTHY_STATUS), + ]); + let analysis = analyze_site_core(&assessment); + + assert_eq!(analysis.results.len(), 1); + assert_eq!(analysis.results[0].state, SccmSiteCoreState::Healthy); + assert_eq!(analysis.results[0].confidence, SccmSiteCoreConfidence::High); +} + +#[test] +fn phase_order_and_post_terminal_evidence_fail_closed() { + let out_of_order = analyze_site_core(&assess(&[ + Source::sitecomp(OUT_OF_ORDER_SITECOMP), + Source::status(OUT_OF_ORDER_STATUS), + ])); + assert_eq!(out_of_order.results.len(), 1); + assert!(out_of_order.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + && result.confidence != SccmSiteCoreConfidence::High + })); + + let later_success = analyze_site_core(&assess(&[ + Source::sitecomp(SUCCESS_AFTER_FAILURE), + Source::absent_status(), + ])); + assert_eq!(later_success.results.len(), 1); + assert!(later_success.results.iter().all(|result| { + result.finding_class != Some(SccmFindingClass::ConfirmedFailure) + && result.confidence != SccmSiteCoreConfidence::High + })); +} + +#[test] +fn terminal_component_and_status_outcomes_require_exact_cited_facts() { + let component = analyze_site_core(&assess(&[ + Source::sitecomp(COMPONENT_FAILURE), + Source::absent_status(), + ])); + assert_eq!(component.results.len(), 1); + assert_eq!( + component.results[0].state, + SccmSiteCoreState::TerminalFailure + ); + assert_eq!( + component.results[0].last_successful_phase, + Some(SccmSiteCorePhase::ComponentWork) + ); + assert_eq!( + component.results[0].finding_class, + Some(SccmFindingClass::ConfirmedFailure) + ); + assert_eq!(component.findings.len(), 2); + let component_failure = component + .findings + .iter() + .find(|finding| finding.finding.class == SccmFindingClass::ConfirmedFailure) + .expect("confirmed component failure finding"); + assert_eq!(component_failure.finding.terminal_evidence.len(), 1); + assert!(component.results[0] + .evidence + .iter() + .any(|evidence| evidence.terminal == Some(true))); + + let status = analyze_site_core(&assess(&[ + Source::sitecomp(STATUS_FAILURE_SITECOMP), + Source::status(STATUS_FAILURE_STATUS), + ])); + assert_eq!(status.results.len(), 1); + assert_eq!(status.results[0].state, SccmSiteCoreState::TerminalFailure); + assert_eq!( + status.results[0].last_successful_phase, + Some(SccmSiteCorePhase::StatusOrStateProcessing) + ); + assert_eq!( + status.results[0].finding_class, + Some(SccmFindingClass::ConfirmedFailure) + ); + assert_eq!(status.findings.len(), 1); + assert_eq!(status.findings[0].finding.terminal_evidence.len(), 1); +} + +#[test] +fn backlog_is_deferred_and_same_component_terminal_recovery_is_cited() { + let backlog = analyze_site_core(&assess(&[ + Source::sitecomp(INBOX_BACKLOG), + Source::absent_status(), + ])); + assert_eq!(backlog.results.len(), 1); + assert_eq!( + backlog.results[0].state, + SccmSiteCoreState::BlockedOrDeferred + ); + assert_eq!(backlog.results[0].confidence, SccmSiteCoreConfidence::Low); + assert_eq!( + backlog.results[0].last_successful_phase, + Some(SccmSiteCorePhase::ComponentWork) + ); + assert!(backlog.results[0] + .next_artifacts + .iter() + .any(|request| request.logical_name == "server-status")); + + let recovery = analyze_site_core(&assess(&[ + Source::sitecomp(RECOVERY_SITECOMP), + Source::status(RECOVERY_STATUS), + ])); + assert_eq!(recovery.results.len(), 1); + assert_eq!(recovery.results[0].state, SccmSiteCoreState::Recovered); + assert_eq!( + recovery.results[0].finding_class, + Some(SccmFindingClass::Symptom) + ); + assert!(recovery.results[0] + .evidence + .iter() + .any(|evidence| evidence.recovery == Some(true))); +} + +#[test] +fn result_without_confirmed_success_uses_unconfirmed_finding_phase() { + let analysis = analyze_site_core(&assess(&[ + Source::sitecomp(TERMINAL_FAILURE_WITHOUT_SUCCESS), + Source::absent_status(), + ])); + + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.last_successful_phase, None); + assert_eq!( + result.finding_class, + Some(SccmFindingClass::InsufficientEvidence) + ); + let finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == result.result_id) + .expect("unconfirmed result has a conservative finding"); + assert_eq!( + serde_json::to_value(&finding.finding).expect("finding serializes")["phase"], + "siteCoreUnconfirmed" + ); +} + +#[test] +fn unrelated_same_minute_components_and_producer_hosts_never_merge() { + let assessment = assess(&[ + Source::sitecomp(CONTRADICTORY_SITECOMP), + Source::status(CONTRADICTORY_STATUS), + ]); + let analysis = analyze_site_core(&assessment); + assert_eq!(analysis.results.len(), 2); + assert_ne!( + analysis.results[0].transaction_key.component_id, + analysis.results[1].transaction_key.component_id + ); + assert!(analysis + .results + .iter() + .any(|result| result.state == SccmSiteCoreState::Healthy)); + assert!(analysis + .results + .iter() + .any(|result| result.state == SccmSiteCoreState::TerminalFailure)); + + let split_hosts = assess_with_producer_hosts( + &[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ], + &[("server-status", "synthetic:host:mp-01")], + ); + let split = analyze_site_core(&split_hosts); + assert_eq!(split.results.len(), 2); + assert_ne!( + split.results[0].transaction_key.producer_host_handle, + split.results[1].transaction_key.producer_host_handle + ); + assert!(split.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + assert!(split + .results + .iter() + .any(|result| { result.transaction_key.producer_host_handle == "synthetic:host:site-01" })); + assert!(split + .results + .iter() + .any(|result| { result.transaction_key.producer_host_handle == "synthetic:host:mp-01" })); + + let foreign_gap = assess_with_producer_hosts( + &[Source::sitecomp(HEALTHY_SITECOMP), Source::absent_status()], + &[("server-status", "synthetic:host:mp-01")], + ); + let foreign_gap_analysis = analyze_site_core(&foreign_gap); + assert_eq!(foreign_gap_analysis.results.len(), 1); + assert_eq!( + foreign_gap_analysis.results[0] + .coverage_gap_artifact_ids + .len(), + 1 + ); + let local_gap_id = &foreign_gap_analysis.results[0].coverage_gap_artifact_ids[0]; + assert!(local_gap_id.starts_with("site-core:missing-source:v1:")); + assert_ne!(local_gap_id, "z-site-status"); + assert!(foreign_gap_analysis.coverage_gaps.iter().any(|gap| { + gap.artifact_id == *local_gap_id + && gap.source_id == "server-status" + && gap.state == SccmCoverageState::Absent + && gap.reason_code == "required-status-source-not-declared" + })); + assert!(!foreign_gap_analysis.results[0].next_artifacts.is_empty()); + assert!(foreign_gap_analysis.results[0] + .next_artifacts + .iter() + .all(|request| request.scope.producer_host_handle.as_deref() + == Some("synthetic:host:site-01"))); + for request in &foreign_gap_analysis.results[0].next_artifacts { + assert_bounded_request_has_specific_scope(request); + } +} + +#[test] +fn encoding_profile_coverage_fragment_cap_and_time_provenance_fail_closed() { + let mut wrong_encoding = Source::sitecomp(COMPONENT_FAILURE); + wrong_encoding.encoding = Some("windows-1252"); + let encoding = assess(&[wrong_encoding, Source::absent_status()]); + + let mut unknown_profile = + assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + let profiled = unknown_profile + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact"); + profiled.profile_eligible = false; + profiled.source_version = Some( + "cmtraceopen.version.sha256.v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_owned(), + ); + + let mut denied = assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + denied + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .state = SccmCoverageState::AccessDenied; + + let mut incomplete_fragment = + assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + incomplete_fragment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .fragment_complete = Some(false); + + let mut missing_content_provenance = + assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + let missing_content = missing_content_provenance + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact"); + missing_content.content_sha256 = None; + missing_content.relative_path = None; + + let capped = assess(&[ + Source::capped_sitecomp(COMPONENT_FAILURE), + Source::absent_status(), + ]); + + let mut invalid_time = assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + for evidence in &mut invalid_time.evidence { + evidence.timestamp.offset_minutes = None; + evidence.timestamp.utc_millis = None; + evidence.timestamp.ordering_state = SccmTimeOrderingState::OffsetInvalid; + } + + for (name, assessment) in [("encoding", encoding), ("cap", capped)] { + let analysis = analyze_site_core(&assessment); + assert!( + !analysis.coverage_gaps.is_empty(), + "{name} provenance must remain an explicit coverage gap" + ); + assert!( + !analysis.unlinked_observations.is_empty(), + "{name} provenance must remain an explicit observation" + ); + assert!( + !analysis.artifact_requests.is_empty(), + "{name} provenance must retain an actionable request" + ); + assert!( + !analysis.findings.is_empty(), + "{name} provenance must retain a conservative finding" + ); + assert!( + analysis.results.iter().all(|result| { + result.finding_class != Some(SccmFindingClass::ConfirmedFailure) + && result.confidence != SccmSiteCoreConfidence::High + }), + "{name} provenance produced a high-confidence terminal outcome" + ); + assert!(analysis.findings.iter().all(|finding| { + finding.finding.class != SccmFindingClass::ConfirmedFailure + || finding.finding.confidence != cmtraceopen_parser::sccm::SccmConfidence::High + })); + } + + for assessment in [ + unknown_profile, + denied, + incomplete_fragment, + missing_content_provenance, + invalid_time, + ] { + assert_authority_invalid_analysis(&analyze_site_core(&assessment)); + } +} + +#[test] +fn rotation_split_fragments_are_coverage_not_a_terminal_transaction() { + let assessment = assess(&[ + Source::sitecomp(ROTATION_CURRENT_FRAGMENT), + Source::sitecomp_lo_fragment(ROTATION_LO_FRAGMENT), + ]); + assert_eq!(assessment.artifacts.len(), 2); + assert!(assessment + .artifacts + .iter() + .all(|artifact| artifact.state == SccmCoverageState::ParseFailed)); + + let analysis = analyze_site_core(&assessment); + assert!(analysis.results.is_empty()); + assert_eq!(analysis.coverage_gaps.len(), 2); + assert!(analysis + .coverage_gaps + .iter() + .all(|gap| gap.state == SccmCoverageState::ParseFailed)); + assert_eq!(analysis.findings.len(), 2); + assert!(analysis + .findings + .iter() + .all(|finding| finding.finding.class != SccmFindingClass::ConfirmedFailure)); +} + +#[test] +fn incomplete_sources_are_coverage_states_not_role_health_claims() { + let analysis = analyze_site_core(&assess(&[ + Source::capped_sitecomp(HEALTHY_SITECOMP), + Source::absent_status(), + ])); + + assert!(analysis.results.is_empty()); + assert!(analysis.coverage_gaps.iter().any(|gap| { + gap.artifact_id == "sitecomp-current" && gap.state == SccmCoverageState::Capped + })); + assert!(analysis.coverage_gaps.iter().any(|gap| { + gap.artifact_id == "z-site-status" && gap.state == SccmCoverageState::Absent + })); + assert!(!analysis.findings.is_empty()); + assert!(analysis + .findings + .iter() + .all(|finding| finding.finding.class != SccmFindingClass::ConfirmedFailure)); +} + +#[test] +fn undeclared_status_source_is_an_explicit_host_scoped_coverage_gap() { + let assessment = assess(&[Source::sitecomp(HEALTHY_SITECOMP)]); + assert!(assessment + .artifacts + .iter() + .all(|artifact| artifact.source_id != "server-status")); + assert!(!assessment.evidence.is_empty()); + + let analysis = analyze_site_core(&assessment); + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.state, SccmSiteCoreState::Incomplete); + assert_eq!( + result.finding_class, + Some(SccmFindingClass::InsufficientEvidence) + ); + assert!(!result.evidence.is_empty()); + assert!(!analysis.cross_side_correlation_performed); + + assert_eq!(analysis.coverage_gaps.len(), 1); + let gap = &analysis.coverage_gaps[0]; + assert_eq!(gap.source_id, "server-status"); + assert_eq!(gap.state, SccmCoverageState::Absent); + assert_eq!(gap.reason_code, "required-status-source-not-declared"); + assert!(gap.artifact_id.starts_with("site-core:missing-source:v1:")); + assert_eq!( + result.coverage_gap_artifact_ids, + vec![gap.artifact_id.clone()] + ); + + let result_finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == result.result_id) + .expect("insufficient-evidence result has a validated finding"); + assert_eq!( + result_finding.finding.class, + SccmFindingClass::InsufficientEvidence + ); + assert!(result_finding + .finding + .coverage_gaps + .iter() + .any(|finding_gap| finding_gap.artifact_id == gap.artifact_id)); + + let status_requests = analysis + .artifact_requests + .iter() + .filter(|request| request.logical_name == "server-status") + .collect::>(); + assert!(!status_requests.is_empty()); + for request in status_requests { + assert_bounded_request_has_specific_scope(request); + assert_eq!( + request.scope.producer_host_handle.as_deref(), + Some("synthetic:host:site-01") + ); + } +} + +#[test] +fn undeclared_component_source_is_an_explicit_host_component_work_item_scoped_coverage_gap() { + let assessment = assess(&[Source::status(HEALTHY_STATUS)]); + assert!(assessment + .artifacts + .iter() + .all(|artifact| artifact.source_id != "server-sitecomp")); + assert!(!assessment.evidence.is_empty()); + + let analysis = analyze_site_core(&assessment); + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.state, SccmSiteCoreState::Incomplete); + assert_eq!( + result.finding_class, + Some(SccmFindingClass::InsufficientEvidence) + ); + assert!(!result.evidence.is_empty()); + + assert_eq!(analysis.coverage_gaps.len(), 1); + let gap = &analysis.coverage_gaps[0]; + assert_eq!(gap.source_id, "server-sitecomp"); + assert_eq!(gap.state, SccmCoverageState::Absent); + assert_eq!(gap.reason_code, "required-component-source-not-declared"); + assert!(gap.artifact_id.starts_with("site-core:missing-source:v1:")); + assert!(!gap.artifact_id.contains("site-01")); + assert_eq!( + result.coverage_gap_artifact_ids, + vec![gap.artifact_id.clone()] + ); + + let result_finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == result.result_id) + .expect("insufficient-evidence result has a validated finding"); + assert_eq!( + result_finding.finding.class, + SccmFindingClass::InsufficientEvidence + ); + assert_eq!(result_finding.finding.coverage_gaps.len(), 1); + assert_eq!( + result_finding.finding.coverage_gaps[0].artifact_id, + gap.artifact_id + ); + + let component_requests = analysis + .artifact_requests + .iter() + .filter(|request| { + request.logical_name == "server-sitecomp" + && request.scope.producer_host_handle.as_deref() == Some("synthetic:host:site-01") + && request.scope.component_id.as_deref() == Some("SMS_EXECUTIVE") + && request.scope.work_item_id.as_deref() == Some("SC-HEALTH-001") + }) + .collect::>(); + assert_eq!(component_requests.len(), 1); + let request = component_requests[0]; + assert_eq!(request.reason_code, "matching-component-evidence-missing"); + assert_eq!(request.max_artifacts, 2); + assert_eq!( + request + .candidates + .iter() + .map(|candidate| (candidate.basename.as_str(), candidate.rotation.as_str())) + .collect::>(), + vec![ + ("sitecomp.log", "current"), + ("sitecomp.lo_", "loUnderscore") + ] + ); + assert_eq!( + request.scope.producer_host_handle.as_deref(), + Some("synthetic:host:site-01") + ); + assert_eq!(request.scope.component_id.as_deref(), Some("SMS_EXECUTIVE")); + assert_eq!(request.scope.work_item_id.as_deref(), Some("SC-HEALTH-001")); + assert_eq!(request.scope.rotation_lineage_handle, None); +} + +#[test] +fn malformed_status_peer_cannot_hide_required_status_coverage() { + for malformed_id in ["a".repeat(300), "invalid/status".to_owned()] { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + replace_source_artifact_id(&mut assessment, "server-status", &malformed_id); + + assert_malformed_peer_source_fails_closed(&analyze_site_core(&assessment), &malformed_id); + } +} + +#[test] +fn malformed_component_peer_cannot_hide_required_component_coverage() { + for malformed_id in ["a".repeat(300), "invalid/component".to_owned()] { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + replace_source_artifact_id(&mut assessment, "server-sitecomp", &malformed_id); + + assert_malformed_peer_source_fails_closed(&analyze_site_core(&assessment), &malformed_id); + } +} + +#[test] +fn undeclared_component_gap_requires_admitted_status_facts() { + let unrelated_status = HEALTHY_STATUS.replace("profileId=sccm-site-core", "profileId=other"); + let analysis = analyze_site_core(&assess(&[Source::status(&unrelated_status)])); + + assert!(analysis.results.is_empty()); + assert!(analysis.coverage_gaps.is_empty()); + assert!(!analysis.cross_side_correlation_performed); +} + +#[test] +fn undeclared_component_gap_is_deterministic_under_status_only_assessment_permutation() { + let assessment = assess(&[Source::status(HEALTHY_STATUS)]); + let mut reordered = assessment.clone(); + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + reordered.next_artifact_requests.reverse(); + + assert_eq!( + serde_json::to_vec(&analyze_site_core(&assessment)).expect("analysis serializes"), + serde_json::to_vec(&analyze_site_core(&reordered)).expect("analysis serializes") + ); +} + +#[test] +fn undeclared_component_gap_does_not_attach_across_producer_hosts() { + let assessment = assess_with_producer_hosts( + &[ + Source::status(HEALTHY_STATUS), + Source::sitecomp(HEALTHY_SITECOMP), + ], + &[("server-sitecomp", "synthetic:host:mp-01")], + ); + + let analysis = analyze_site_core(&assessment); + let status_only_result = analysis + .results + .iter() + .find(|result| result.transaction_key.producer_host_handle == "synthetic:host:site-01") + .expect("status-only host result"); + assert_eq!(status_only_result.coverage_gap_artifact_ids.len(), 1); + let local_gap_id = &status_only_result.coverage_gap_artifact_ids[0]; + let local_gap = analysis + .coverage_gaps + .iter() + .find(|gap| gap.artifact_id == *local_gap_id) + .expect("status-only host component gap"); + assert_eq!(local_gap.source_id, "server-sitecomp"); + assert_eq!( + local_gap.reason_code, + "required-component-source-not-declared" + ); + assert!(status_only_result.next_artifacts.iter().all(|request| { + request.scope.producer_host_handle.as_deref() == Some("synthetic:host:site-01") + })); + + let foreign_component_result = analysis + .results + .iter() + .find(|result| result.transaction_key.producer_host_handle == "synthetic:host:mp-01") + .expect("foreign component host result"); + assert!(foreign_component_result + .coverage_gap_artifact_ids + .iter() + .all(|gap_id| gap_id != local_gap_id)); +} + +#[test] +fn site_core_output_is_byte_identical_after_assessment_reordering() { + let assessment = assess(&[ + Source::sitecomp(CONTRADICTORY_SITECOMP), + Source::status(CONTRADICTORY_STATUS), + ]); + let mut reordered = assessment.clone(); + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + reordered.next_artifact_requests.reverse(); + + assert_eq!( + serde_json::to_vec(&analyze_site_core(&assessment)).expect("analysis serializes"), + serde_json::to_vec(&analyze_site_core(&reordered)).expect("analysis serializes") + ); +} + +#[test] +fn no_provenance_mutation_can_reintroduce_a_confirmed_failure() { + let mut assessment = assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .capture_provenance + .as_mut() + .expect("captured source provenance") + .limit_applied = true; + + assert_authority_invalid_analysis(&analyze_site_core(&assessment)); +} + +#[test] +fn post_intake_source_contract_mutations_fail_sealed_authority_closed() { + let healthy = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + + let mut wrong_role = healthy.clone(); + wrong_role + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-status") + .expect("status artifact") + .producer_role = SccmRole::ManagementPoint; + assert_authority_invalid_analysis(&analyze_site_core(&wrong_role)); + + let mut wrong_subject = healthy.clone(); + let sitecomp = wrong_subject + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact"); + sitecomp.workflow_subject_role = Some(SccmRole::Client); + sitecomp.workflow_subject_handle = Some("synthetic:subject:client-01".to_owned()); + assert_authority_invalid_analysis(&analyze_site_core(&wrong_subject)); + + let mut missing_producer_host = healthy.clone(); + missing_producer_host + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .producer_host_handle = None; + assert_intake_authority_mutation_fails_closed( + &analyze_site_core(&missing_producer_host), + &missing_producer_host, + ); + + let mut duplicate = healthy; + let duplicate_sitecomp = duplicate + .artifacts + .iter() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .clone(); + duplicate.artifacts.push(duplicate_sitecomp); + assert_authority_invalid_analysis(&analyze_site_core(&duplicate)); + + let mut rejected_shape = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + rejected_shape + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-status") + .expect("status artifact") + .original_basename = Some("future-status.bin".to_owned()); + assert_authority_invalid_analysis(&analyze_site_core(&rejected_shape)); +} + +#[test] +fn post_intake_evidence_mutations_fail_sealed_authority_closed() { + let healthy = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + + let mut wrong_role = healthy.clone(); + wrong_role.evidence[0].role = SccmRole::ManagementPoint; + assert_intake_authority_mutation_fails_closed(&analyze_site_core(&wrong_role), &wrong_role); + + let mut incomplete_reference = healthy.clone(); + incomplete_reference.evidence[0].reference.line_end = None; + assert_intake_authority_mutation_fails_closed( + &analyze_site_core(&incomplete_reference), + &incomplete_reference, + ); + + let mut cross_source_reference = healthy.clone(); + cross_source_reference.evidence[0].reference.artifact_id = "z-site-status".to_owned(); + cross_source_reference.evidence[0].reference.line_start = Some(10_001); + cross_source_reference.evidence[0].reference.line_end = Some(10_001); + assert_intake_authority_mutation_fails_closed( + &analyze_site_core(&cross_source_reference), + &cross_source_reference, + ); + + let mut unresolved_reference = healthy; + unresolved_reference.evidence[0].reference.artifact_id = "orphan-sitecomp-record".to_owned(); + assert_intake_authority_mutation_fails_closed( + &analyze_site_core(&unresolved_reference), + &unresolved_reference, + ); +} + +#[test] +fn foreign_post_intake_artifact_identity_cannot_scope_a_site_core_request() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + let mut foreign_artifact = assessment.artifacts[0].clone(); + foreign_artifact.artifact_id = "foreign-artifact".to_owned(); + foreign_artifact.source_id = "server-foreign".to_owned(); + foreign_artifact.producer_host_handle = Some("synthetic:host:foreign".to_owned()); + foreign_artifact.rotation_lineage_handle = "foreign-lineage".to_owned(); + assessment.artifacts.push(foreign_artifact); + assessment.evidence[0].reference.artifact_id = "foreign-artifact".to_owned(); + + let analysis = analyze_site_core(&assessment); + assert_intake_authority_mutation_fails_closed(&analysis, &assessment); + assert!(analysis.artifact_requests.iter().all(|request| request + .scope + .producer_host_handle + .as_deref() + != Some("synthetic:host:foreign") + && request.scope.rotation_lineage_handle.as_deref() != Some("foreign-lineage"))); +} + +#[test] +fn post_intake_nonprofile_role_mutation_fails_sealed_authority_closed() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + assessment.evidence[0].message = "ordinary non-profile source prose".to_owned(); + assessment.evidence[0].role = SccmRole::ManagementPoint; + + assert_intake_authority_mutation_fails_closed(&analyze_site_core(&assessment), &assessment); +} + +#[test] +fn post_intake_evidence_identity_collision_fails_sealed_authority_closed() { + let mut assessment = assess(&[Source::sitecomp(HEALTHY_SITECOMP), Source::absent_status()]); + let duplicate = assessment.evidence[0].clone(); + assessment.evidence.push(duplicate); + + assert_authority_invalid_analysis(&analyze_site_core(&assessment)); +} + +#[test] +fn closed_profile_schema_rejects_arbitrary_keys_and_retains_safe_unknown_facts() { + let arbitrary_sitecomp = + HEALTHY_SITECOMP.replace("workItemId=SC-HEALTH-001", "workItemId=ARBITRARY-001"); + let arbitrary_status = + HEALTHY_STATUS.replace("workItemId=SC-HEALTH-001", "workItemId=ARBITRARY-001"); + let arbitrary_work = assess(&[ + Source::sitecomp(&arbitrary_sitecomp), + Source::status(&arbitrary_status), + ]); + let arbitrary = analyze_site_core(&arbitrary_work); + assert!(arbitrary.results.is_empty()); + assert_eq!( + arbitrary.unlinked_observations.len(), + arbitrary_work.evidence.len() + ); + let arbitrary_wire = serde_json::to_string(&arbitrary).expect("analysis serializes"); + assert!(arbitrary_work + .evidence + .iter() + .all(|evidence| arbitrary_wire.contains(&evidence.evidence_id))); + + let unknown_sitecomp = + HEALTHY_SITECOMP.replace("SC_COMPONENT_START_OK", "SC_UNREVIEWED_STATUS"); + let unknown_status = assess(&[ + Source::sitecomp(&unknown_sitecomp), + Source::status(HEALTHY_STATUS), + ]); + let rejected_id = unknown_status.evidence[0].evidence_id.clone(); + let unknown = analyze_site_core(&unknown_status); + let unknown_wire = serde_json::to_string(&unknown).expect("analysis serializes"); + assert!(unknown_wire.contains(&rejected_id)); + let observation = unknown + .unlinked_observations + .iter() + .find(|observation| { + observation + .evidence + .iter() + .any(|evidence| evidence.entry_id == rejected_id) + }) + .expect("rejected record remains a source-local observation"); + assert_eq!(observation.finding_class, SccmFindingClass::Symptom); + assert_eq!(observation.coverage_gap_artifact_ids, Vec::::new()); + assert_eq!(observation.next_artifacts.len(), 1); + assert_eq!(observation.next_artifacts[0].candidates.len(), 1); + assert_eq!( + observation.next_artifacts[0].candidates[0].basename, + "sitecomp.log" + ); + assert_eq!( + observation.next_artifacts[0].candidates[0].rotation, + "current" + ); + let finding = unknown + .findings + .iter() + .find(|finding| finding.subject_id == observation.observation_id) + .expect("rejected record has a conservative finding"); + assert_eq!( + finding.finding.title, + "Unrecognized site core profile record" + ); + assert!(finding.finding.coverage_gaps.is_empty()); +} + +#[test] +fn semicolon_attached_unknown_profile_labels_fail_closed_in_both_orders() { + assert_delimiter_attached_unknown_label_fails_closed(';'); +} + +#[test] +fn comma_attached_unknown_profile_labels_fail_closed_in_both_orders() { + assert_delimiter_attached_unknown_label_fails_closed(','); +} + +#[test] +fn ampersand_attached_unknown_profile_labels_fail_closed_in_both_orders() { + assert_delimiter_attached_unknown_label_fails_closed('&'); +} + +#[test] +fn delimiter_separated_known_profile_labels_and_safe_prose_remain_accepted() { + for delimiter in [';', ',', '&'] { + let joined_fields = + format!("outcome=success{delimiter}terminal=false harmless prose tokens"); + let sitecomp = HEALTHY_SITECOMP.replace("outcome=success terminal=false", &joined_fields); + let status = HEALTHY_STATUS.replace("outcome=success terminal=false", &joined_fields); + let analysis = analyze_site_core(&assess(&[ + Source::sitecomp(&sitecomp), + Source::status(&status), + ])); + + assert_eq!(analysis.results.len(), 1, "delimiter {delimiter:?}"); + assert_eq!( + analysis.results[0].state, + SccmSiteCoreState::Healthy, + "delimiter {delimiter:?}" + ); + assert!( + analysis.unlinked_observations.is_empty(), + "delimiter {delimiter:?}" + ); + } +} + +#[test] +fn every_required_source_coverage_state_emits_insufficient_evidence_and_a_request() { + for (state_token, state) in [ + ("absent", SccmCoverageState::Absent), + ("accessDenied", SccmCoverageState::AccessDenied), + ("skipped", SccmCoverageState::Skipped), + ("unsupported", SccmCoverageState::Unsupported), + ] { + let mut sitecomp = Source::sitecomp(HEALTHY_SITECOMP); + sitecomp.content = None; + sitecomp.capture_state = state_token; + sitecomp.encoding = None; + let assessment = assess(&[sitecomp, Source::status(HEALTHY_STATUS)]); + + let analysis = analyze_site_core(&assessment); + assert!(analysis + .coverage_gaps + .iter() + .any(|gap| { gap.artifact_id == "sitecomp-current" && gap.state == state })); + assert!(analysis.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::InsufficientEvidence + && observation + .coverage_gap_artifact_ids + .contains(&"sitecomp-current".to_owned()) + })); + assert!(analysis + .artifact_requests + .iter() + .any(|request| request.logical_name == "server-sitecomp")); + assert_eq!(analysis.results.len(), 1); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + && result.finding_class != Some(SccmFindingClass::ConfirmedFailure) + })); + } +} + +#[test] +fn generated_result_and_finding_ids_are_bounded_stable_and_opaque() { + let analysis = analyze_site_core(&assess(&[ + Source::sitecomp(COMPONENT_FAILURE), + Source::absent_status(), + ])); + assert_eq!(analysis.results.len(), 1); + assert_eq!(analysis.findings.len(), 2); + let result = &analysis.results[0]; + assert!(result.result_id.starts_with("site-core:result:v1:")); + assert_eq!(result.result_id.len(), "site-core:result:v1:".len() + 64); + assert!(!result + .result_id + .contains(&result.transaction_key.component_id)); + assert!(!result + .result_id + .contains(&result.transaction_key.work_item_id)); + assert!(analysis.findings.iter().all(|finding| { + finding + .finding + .finding_id + .starts_with("site-core:finding:v1:") + && finding.finding.finding_id.len() == "site-core:finding:v1:".len() + 64 + })); +} + +#[test] +fn invalid_finding_inputs_become_explicit_gaps_instead_of_clearing_class() { + let mut assessment = assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + let oversized_id = "a".repeat(300); + assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .artifact_id = oversized_id.clone(); + for coverage in &mut assessment.coverage { + for artifact_id in &mut coverage.artifact_ids { + if artifact_id == "sitecomp-current" { + *artifact_id = oversized_id.clone(); + } + } + } + for evidence in &mut assessment.evidence { + if evidence.reference.artifact_id == "sitecomp-current" { + evidence.reference.artifact_id = oversized_id.clone(); + evidence.evidence_id = format!("{oversized_id}:{}", evidence.evidence_id); + evidence.reference.entry_id = evidence.evidence_id.clone(); + } + } + + let analysis = analyze_site_core(&assessment); + assert_authority_invalid_analysis(&analysis); + assert!(!serde_json::to_string(&analysis) + .expect("analysis serializes") + .contains(&oversized_id)); +} + +#[test] +fn committed_site_core_corpus_exactly_matches_every_serialized_output() { + let scenarios = [ + "healthy", + "component-failure", + "inbox-backlog", + "status-processing-failure", + "recovery", + "contradictory", + "rotation-boundary", + "incomplete", + "malformed", + ]; + let corpus = scenarios + .iter() + .map(|scenario| load_corpus_scenario(scenario)) + .collect::>(); + for (scenario, (assessment, expected)) in scenarios.into_iter().zip(corpus) { + assert_eq!( + serde_json::to_value(analyze_site_core(&assessment)) + .expect("site-core analysis serializes"), + expected, + "corpus scenario {scenario} diverged" + ); + } +} + +#[test] +fn later_same_phase_success_clears_deferred_but_unrecovered_deferred_remains() { + let cleared = analyze_site_core(&assess(&[ + Source::sitecomp(DEFERRED_THEN_ACCEPTED), + Source::absent_status(), + ])); + assert_eq!(cleared.results.len(), 1); + assert_eq!(cleared.results[0].state, SccmSiteCoreState::Incomplete); + assert_eq!( + cleared.results[0].finding_class, + Some(SccmFindingClass::InsufficientEvidence) + ); + + let pending = analyze_site_core(&assess(&[ + Source::sitecomp(INBOX_BACKLOG), + Source::absent_status(), + ])); + assert_eq!(pending.results.len(), 1); + assert_eq!( + pending.results[0].state, + SccmSiteCoreState::BlockedOrDeferred + ); +} + +#[test] +fn rotation_provenance_must_match_classification_and_requests_use_exact_pairs() { + let mut mismatch = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + mismatch + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .rotation = Some(SccmRotation::LoUnderscore); + let rejected = analyze_site_core(&mismatch); + assert_intake_authority_mutation_fails_closed(&rejected, &mismatch); + + let backlog = analyze_site_core(&assess(&[ + Source::sitecomp(INBOX_BACKLOG), + Source::absent_status(), + ])); + let request = backlog + .artifact_requests + .iter() + .find(|request| request.logical_name == "server-status") + .expect("bounded status request"); + let request_wire = serde_json::to_value(request).expect("request serializes"); + assert_eq!( + request_wire["candidates"], + json!([ + {"basename": "statmgr.log", "rotation": "current"}, + {"basename": "statmgr.lo_", "rotation": "loUnderscore"} + ]) + ); + assert!(request_wire.get("basenames").is_none()); + assert!(request_wire.get("rotations").is_none()); + + let mut unknown_rotation = assess(&[ + Source::capped_sitecomp(HEALTHY_SITECOMP), + Source::absent_status(), + ]); + unknown_rotation + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .rotation = Some(SccmRotation::Unknown(SccmUnknownRotation { + kind: "future".to_owned(), + value: None, + })); + assert_authority_invalid_analysis(&analyze_site_core(&unknown_rotation)); +} + +#[test] +fn intake_coverage_must_be_congruent_before_facts_can_shape_results() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + assessment.coverage.clear(); + + let analysis = analyze_site_core(&assessment); + assert_authority_invalid_analysis(&analysis); +} + +#[test] +fn coordinated_post_intake_producer_host_mutation_fails_site_core_authority_closed() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + replace_source_producer_host(&mut assessment, "server-sitecomp", "synthetic:host:forged"); + replace_source_producer_host(&mut assessment, "server-status", "synthetic:host:forged"); + + let analysis = analyze_site_core(&assessment); + assert_invalid_authority_excludes_source_triples( + &analysis, + &[ + ( + "sitecomp-current", + "server-sitecomp", + Some("synthetic:host:forged"), + ), + ( + "z-site-status", + "server-status", + Some("synthetic:host:forged"), + ), + ], + ); +} + +#[test] +fn invalid_intake_authority_never_exports_forged_scope_or_identity() { + let healthy = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + + let forged_host = "synthetic:host:forged-scope"; + let mut host_mutation = healthy.clone(); + replace_source_producer_host(&mut host_mutation, "server-sitecomp", forged_host); + replace_source_producer_host(&mut host_mutation, "server-status", forged_host); + + let forged_lineage = "synthetic:lineage:forged-scope"; + let mut lineage_mutation = healthy.clone(); + for artifact in &mut lineage_mutation.artifacts { + artifact.rotation_lineage_handle = forged_lineage.to_owned(); + } + + let forged_artifact_id = "synthetic:artifact:forged-scope"; + let mut artifact_id_mutation = healthy; + replace_source_artifact_id( + &mut artifact_id_mutation, + "server-sitecomp", + forged_artifact_id, + ); + + let analyses = [ + (forged_host, analyze_site_core(&host_mutation)), + (forged_lineage, analyze_site_core(&lineage_mutation)), + (forged_artifact_id, analyze_site_core(&artifact_id_mutation)), + ]; + for (forged_value, analysis) in &analyses { + assert_authority_invalid_analysis(analysis); + let wire = serde_json::to_string(analysis).expect("analysis serializes"); + assert!( + !wire.contains(forged_value), + "invalid authority exported forged value {forged_value}" + ); + } + assert!(analyses.windows(2).all(|pair| { + serde_json::to_vec(&pair[0].1).expect("analysis serializes") + == serde_json::to_vec(&pair[1].1).expect("analysis serializes") + })); +} + +fn assert_invalid_authority_excludes_source_triples( + analysis: &SccmSiteCoreAnalysis, + forbidden_source_triples: &[(&str, &str, Option<&str>)], +) { + assert_authority_invalid_analysis(analysis); + assert!( + !forbidden_source_triples.is_empty(), + "authority assertion requires at least one source identity" + ); + let wire = serde_json::to_string(analysis).expect("analysis serializes"); + for &(artifact_id, source_id, producer_host_handle) in forbidden_source_triples { + for forged_or_untrusted_value in [Some(artifact_id), Some(source_id), producer_host_handle] + .into_iter() + .flatten() + { + assert!( + !forged_or_untrusted_value.trim().is_empty(), + "authority assertion received a blank source identity" + ); + assert!( + !wire.contains(forged_or_untrusted_value), + "invalid authority exported untrusted value {forged_or_untrusted_value}" + ); + } + } +} + +#[test] +fn swapped_coverage_producer_hosts_fail_site_core_congruence_closed() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + replace_source_producer_host(&mut assessment, "server-status", "synthetic:host:site-02"); + assessment + .coverage + .iter_mut() + .find(|coverage| coverage.source_id == "server-sitecomp") + .expect("sitecomp coverage") + .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + assessment + .coverage + .iter_mut() + .find(|coverage| coverage.source_id == "server-status") + .expect("status coverage") + .producer_host_handle = Some("synthetic:host:site-01".to_owned()); + + let analysis = analyze_site_core(&assessment); + assert_invalid_authority_excludes_source_triples( + &analysis, + &[ + ( + "sitecomp-current", + "server-sitecomp", + Some("synthetic:host:site-01"), + ), + ( + "z-site-status", + "server-status", + Some("synthetic:host:site-02"), + ), + ], + ); +} + +#[test] +fn changed_coverage_workflow_subject_handle_fails_site_core_congruence_closed() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + assessment + .coverage + .iter_mut() + .find(|coverage| coverage.source_id == "server-sitecomp") + .expect("sitecomp coverage") + .workflow_subject_handle = Some("synthetic:subject:site-core-01".to_owned()); + + let analysis = analyze_site_core(&assessment); + assert_intake_authority_mutation_fails_closed(&analysis, &assessment); +} + +#[test] +fn post_intake_topology_mutations_fail_site_core_authority_closed() { + let assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + assert_eq!( + analyze_site_core(&assessment).results[0].state, + SccmSiteCoreState::Healthy, + "the canonical control assessment must exercise normal fact reduction" + ); + + let mut changed_site_handle = assessment.clone(); + changed_site_handle.topology.site_handle = "synthetic:site:other".to_owned(); + + let mut changed_capture_host = assessment.clone(); + changed_capture_host.topology.capture_host_handle = "synthetic:host:site-02".to_owned(); + + let mut changed_observed_roles = assessment; + changed_observed_roles + .topology + .roles_observed + .push(SccmRole::ManagementPoint); + + let analyses = [ + ( + "site handle", + &changed_site_handle, + analyze_site_core(&changed_site_handle), + ), + ( + "capture host", + &changed_capture_host, + analyze_site_core(&changed_capture_host), + ), + ( + "observed roles", + &changed_observed_roles, + analyze_site_core(&changed_observed_roles), + ), + ]; + + for (mutation, mutated_assessment, analysis) in &analyses { + assert!( + analysis.results.is_empty(), + "{mutation} mutation still produced site-core results" + ); + assert_intake_authority_mutation_fails_closed(analysis, mutated_assessment); + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs new file mode 100644 index 000000000..677cf6889 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs @@ -0,0 +1,634 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::server::windows::{ + analyze_software_update_point, assess_server_intake, SccmServerArtifactPayload, + SccmServerIntakeAssessment, SccmSoftwareUpdatePointSourceLocalClassification, +}; +use cmtraceopen_parser::sccm::{ + correlate_updates_software_update_point, SccmClientUpdateCorrelationHandoff, + SccmClientUpdateCoverage, SccmClientUpdateCoverageState, SccmClientUpdateExtractionProfile, + SccmClientUpdatesAnalysis, SccmCorrelationGuard, SccmCorrelationGuardState, + SccmCorrelationReason, SccmCorrelationSide, SccmKeyConfidence, + SccmUpdatesSoftwareUpdatePointInput, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, +}; +use serde_json::Value; + +const SCENARIOS: &[&str] = &[ + "incomplete", + "metadata-failure", + "rotation-boundary", + "sup-setup-failure", + "supplemental-wsus-skipped", + "sync-retry", + "sync-success", + "unrelated-update-key", + "wcm-configuration-failure", + "wsus-health-failure", +]; + +fn corpus_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/server/software_update_point") +} + +fn load_canonical_intake_scenario(scenario: &str) -> SccmServerIntakeAssessment { + let scenario_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/intake") + .join(scenario); + let manifest_json = std::fs::read_to_string(scenario_root.join("manifest.json")) + .expect("canonical manifest is readable"); + let manifest: Value = + serde_json::from_str(&manifest_json).expect("canonical manifest is valid JSON"); + let payloads = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"] + .as_str() + .expect("artifact ID is a string") + .to_owned(), + bytes: std::fs::read(scenario_root.join(relative_path)) + .expect("captured payload is readable"), + }) + }) + .collect::>(); + assess_server_intake(&manifest_json, &payloads).expect("canonical intake is accepted") +} + +fn load_scenario(scenario: &str) -> (SccmServerIntakeAssessment, Value) { + let (manifest, payloads) = prepared_scenario(scenario); + let expected_json = std::fs::read_to_string(corpus_root().join(scenario).join("expected.json")) + .expect("expected output is readable"); + let expected = serde_json::from_str(&expected_json).expect("expected output is valid JSON"); + (assess_prepared_manifest(manifest, &payloads), expected) +} + +fn prepared_scenario(scenario: &str) -> (Value, Vec) { + let (mut manifest, payloads) = raw_scenario(scenario); + canonicalize_preparation_manifest(&mut manifest); + (manifest, payloads) +} + +fn raw_scenario(scenario: &str) -> (Value, Vec) { + let scenario_root = corpus_root().join(scenario); + let manifest_json = + std::fs::read_to_string(scenario_root.join("manifest.json")).expect("manifest is readable"); + let manifest: Value = serde_json::from_str(&manifest_json).expect("manifest is valid JSON"); + let payloads = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .filter_map(|artifact| { + if artifact["producerRole"] == "client" { + return None; + } + let relative_path = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"] + .as_str() + .expect("artifact ID is a string") + .to_owned(), + bytes: std::fs::read(scenario_root.join(relative_path)) + .expect("captured payload is readable"), + }) + }) + .collect::>(); + (manifest, payloads) +} + +fn assess_prepared_manifest( + manifest: Value, + payloads: &[SccmServerArtifactPayload], +) -> SccmServerIntakeAssessment { + let canonical_manifest = serde_json::to_string(&manifest).expect("manifest serializes"); + assess_server_intake(&canonical_manifest, payloads).unwrap_or_else(|error| { + panic!("fixture is accepted by canonical server intake: {error:?}\n{canonical_manifest}") + }) +} + +fn canonicalize_preparation_manifest(manifest: &mut Value) { + // The committed #330 corpus predates the reviewed #335 wire shape. This + // test-only bridge changes structural capture metadata only; artifact IDs, + // source versions, workflow-subject handles, and evidence bytes remain the + // corpus values judged by the production reducer. + let root = manifest.as_object_mut().expect("manifest is an object"); + root.remove("scenario"); + let bundle = root + .remove("bundle") + .expect("preparation manifest has bundle metadata"); + root.insert("bundleRole".to_owned(), bundle["bundleRole"].clone()); + root.insert( + "privacy".to_owned(), + serde_json::json!({ "synthetic": true, "rawPaths": "redacted" }), + ); + + let topology = root["topology"] + .as_object_mut() + .expect("topology is an object"); + topology.remove("supHandle"); + topology.remove("wsusHandle"); + topology.insert( + "captureHost".to_owned(), + Value::String("LAB-CM01".to_owned()), + ); + + root["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + // Canonical server intake is server-only. The client control proves + // that #330 does not ingest #323 output or perform #333 correlation. + .retain(|artifact| artifact["producerRole"] != "client"); + let artifacts = root["artifacts"] + .as_array_mut() + .expect("artifacts are an array"); + let mut lineages = BTreeMap::::new(); + let lineage_slots = ["sitecomp-a", "sitecomp-lab", "sup-sync-cap", "sup-sync-lab"]; + let fingerprint_slots = [ + "synthetic:path:a-mp", + "synthetic:path:a-site", + "synthetic:path:site-default", + "synthetic:path:site-sup-control", + ]; + + for (index, artifact) in artifacts.iter_mut().enumerate() { + let producer_role = artifact["producerRole"] + .as_str() + .expect("producer role is a string") + .to_owned(); + let producer_host = if producer_role == "wsUs" { + "synthetic:host:wsus-01" + } else { + "synthetic:host:site-01" + }; + artifact["producerHostHandle"] = Value::String(producer_host.to_owned()); + + let subject_role = artifact + .as_object_mut() + .expect("artifact is an object") + .remove("workflowSubjectRole") + .expect("workflow subject role is present"); + let subject_handle = artifact + .as_object_mut() + .expect("artifact is an object") + .remove("workflowSubjectHandle") + .expect("workflow subject handle is present"); + artifact["workflowSubject"] = serde_json::json!({ + "role": subject_role, + "instanceHandle": subject_handle, + }); + artifact["originalPath"] = Value::String("REDACTED_SUP_SOURCE".to_owned()); + artifact["configuredPathProvenance"] = serde_json::json!({ + "state": "configured", + "pathFingerprint": fingerprint_slots[index], + }); + artifact + .as_object_mut() + .expect("artifact is an object") + .remove("sanitizedSourcePath"); + artifact + .as_object_mut() + .expect("artifact is an object") + .remove("pathFingerprint"); + + let source_id = artifact["sourceId"] + .as_str() + .expect("source ID is a string") + .to_owned(); + let mut basename = artifact["originalBasename"] + .as_str() + .expect("basename is a string") + .to_owned(); + if artifact["rotation"]["kind"] == "lo_" { + basename = format!("{}.lo_", basename.trim_end_matches(".log")); + artifact["originalBasename"] = Value::String(basename.clone()); + } + let original_lineage = artifact["rotation"]["lineageId"] + .as_str() + .expect("lineage is a string") + .to_owned(); + let rotation_fragment_complete = artifact["rotation"] + .as_object_mut() + .expect("rotation is an object") + .remove("fragmentComplete") + .and_then(|value| value.as_bool()); + + if source_id == "server-sup-wsus" { + artifact["producerHostHandle"] = Value::String("synthetic:host:wsus-01".to_owned()); + artifact["sourceVersion"] = Value::String("5.00.TEST".to_owned()); + artifact["configuredPathProvenance"]["pathFingerprint"] = + Value::String("synthetic:path:sup-wsus-health".to_owned()); + artifact["rotation"] = serde_json::json!({ + "kind": "providerDefined", + "lineageId": "sup-wsus-health", + }); + } else { + let next_slot = lineages.len(); + let canonical_lineage = lineages + .entry(original_lineage) + .or_insert_with(|| lineage_slots[next_slot].to_owned()) + .clone(); + artifact["rotation"]["lineageId"] = Value::String(canonical_lineage); + if rotation_fragment_complete == Some(false) { + artifact["truncated"] = Value::Bool(false); + artifact["fragmentComplete"] = Value::Bool(false); + } + } + + if artifact["bytesCopied"].is_null() { + artifact["bytesCopied"] = Value::from(0); + } + if artifact["relativePath"].is_string() { + let role_segment = if producer_role == "siteServer" { + "site-server" + } else { + "software-update-point" + }; + let rotation_segment = if artifact["rotation"]["kind"] == "lo_" { + "lo_" + } else { + "current" + }; + artifact["relativePath"] = Value::String(format!( + "evidence/sccm/server/{role_segment}/{source_id}/subject-software-update-point/{rotation_segment}/{basename}" + )); + } + } +} + +fn production_projection(mut expected: Value) -> Value { + let object = expected + .as_object_mut() + .expect("expected output is an object"); + object.remove("contractState"); + object.remove("scenario"); + object["coverage"] + .as_array_mut() + .expect("coverage is an array") + .retain(|row| row["artifactId"] != "unrelated-01-client"); + object["sourceLocalObservations"] + .as_array_mut() + .expect("source-local observations are an array") + .retain(|observation| observation["classification"] != "ignoredClientEvidence"); + for request in object["artifactRequests"] + .as_array_mut() + .expect("artifact requests are an array") + { + request["supHandle"] = Value::String("safe:sup:lab-sup-01".to_owned()); + } + expected +} + +fn empty_client_updates_analysis() -> SccmClientUpdatesAnalysis { + SccmClientUpdatesAnalysis { + schema_version: 1, + transactions: Vec::new(), + observations: Vec::new(), + findings: Vec::new(), + coverage: vec![SccmClientUpdateCoverage { + logical_artifact_id: "client-updates".to_owned(), + state: SccmClientUpdateCoverageState::Captured, + }], + extraction_profile: SccmClientUpdateExtractionProfile { + selection_state: "selected".to_owned(), + profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + key_confidence_ceiling: SccmKeyConfidence::Exact, + validated_artifact_families: Vec::new(), + }, + correlation_handoff: SccmClientUpdateCorrelationHandoff { + issue: "#333".to_owned(), + server_prerequisite_issue: "#330".to_owned(), + performed: false, + time_only_eligible: false, + topology_compatibility_evaluated: false, + server_cause_claimed: false, + native_acceptance_claimed: false, + bundle_capture_host_used_as_sup_evidence: false, + counterpart_ready_key_kinds: Vec::new(), + emitted_counterpart_ready_fact: false, + counterpart_ready_facts: Vec::new(), + }, + prohibited_claims: Vec::new(), + } +} + +#[test] +fn every_committed_scenario_runs_through_the_exported_production_analyzer() { + for scenario in SCENARIOS { + let (intake, expected) = load_scenario(scenario); + let actual = serde_json::to_value(analyze_software_update_point(&intake)) + .expect("analysis serializes"); + + assert_eq!( + actual, + production_projection(expected), + "scenario {scenario}" + ); + } +} + +#[test] +fn accepted_sup_rotation_split_triggers_the_typed_correlation_guard() { + let (mut manifest, mut payloads) = prepared_scenario("rotation-boundary"); + for (old_id, new_id) in [ + ("rotation-01-current", "sync-success-01-wcm"), + ("rotation-02-lo", "sync-success-02-wsync"), + ("rotation-03-malformed", "sync-success-03-wsus"), + ] { + manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == old_id) + .expect("rotation artifact exists")["artifactId"] = Value::String(new_id.to_owned()); + payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == old_id) + .expect("rotation payload exists") + .manifest_artifact_id = new_id.to_owned(); + } + + let sup = analyze_software_update_point(&assess_prepared_manifest(manifest, &payloads)); + let split = sup + .source_local_observations + .iter() + .find(|observation| { + observation.classification + == SccmSoftwareUpdatePointSourceLocalClassification::RotationSplit + }) + .expect("accepted endpoint emits the typed rotation split"); + assert_eq!(split.observation_id, "sync-01-split"); + assert!(!split.observation_id.contains("rotation")); + + let updates = empty_client_updates_analysis(); + let correlated = correlate_updates_software_update_point( + &SccmUpdatesSoftwareUpdatePointInput::from_analyses(&updates, &sup), + ); + let result = correlated.results.first().expect("one correlation result"); + assert!(result.guard_checks.iter().any(|check| { + check.guard_id == SccmCorrelationGuard::RotationSplit + && check.state == SccmCorrelationGuardState::Triggered + })); + assert!(result + .reason_codes + .contains(&SccmCorrelationReason::RotationIncomplete)); + let rotation_requests = result + .artifact_requests + .iter() + .filter(|request| request.reason_code == SccmCorrelationReason::RotationIncomplete) + .collect::>(); + assert_eq!(rotation_requests.len(), 1); + assert_eq!(rotation_requests[0].side, SccmCorrelationSide::Server); + assert_eq!(rotation_requests[0].logical_artifact_id, "server-sup-sync"); + + let mut string_decoy = sup; + let decoy = string_decoy + .source_local_observations + .iter_mut() + .find(|observation| { + observation.classification + == SccmSoftwareUpdatePointSourceLocalClassification::RotationSplit + }) + .expect("typed split exists for the negative adapter control"); + decoy.classification = SccmSoftwareUpdatePointSourceLocalClassification::MalformedEvidence; + decoy.observation_id = "rotation-decoy".to_owned(); + let decoy_correlation = correlate_updates_software_update_point( + &SccmUpdatesSoftwareUpdatePointInput::from_analyses(&updates, &string_decoy), + ); + let decoy_result = decoy_correlation + .results + .first() + .expect("one decoy correlation result"); + assert!(decoy_result.guard_checks.iter().any(|check| { + check.guard_id == SccmCorrelationGuard::RotationSplit + && check.state == SccmCorrelationGuardState::Passed + })); + assert!(!decoy_result + .reason_codes + .contains(&SccmCorrelationReason::RotationIncomplete)); + assert!(decoy_result + .artifact_requests + .iter() + .all(|request| request.reason_code != SccmCorrelationReason::RotationIncomplete)); +} + +#[test] +fn sealed_input_order_does_not_change_the_analysis() { + let (intake, _) = load_scenario("sync-success"); + let mut reordered = intake.clone(); + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + reordered.next_artifact_requests.reverse(); + reordered.topology.roles_observed.reverse(); + + assert_eq!( + serde_json::to_vec(&analyze_software_update_point(&intake)) + .expect("original analysis serializes"), + serde_json::to_vec(&analyze_software_update_point(&reordered)) + .expect("reordered analysis serializes") + ); +} + +#[test] +fn tampered_canonical_intake_fails_closed() { + let (mut intake, _) = load_scenario("sync-success"); + intake.artifacts[0].artifact_id = "forged-artifact".to_owned(); + + let serialized = serde_json::to_value(analyze_software_update_point(&intake)) + .expect("fail-closed analysis serializes"); + + assert_eq!(serialized["transactions"], serde_json::json!([])); + assert_eq!(serialized["sourceLocalObservations"], serde_json::json!([])); + assert_eq!(serialized["artifactRequests"], serde_json::json!([])); + assert_eq!(serialized["clientCausalClaims"], serde_json::json!([])); + assert_eq!(serialized["correlationHandoff"]["performed"], false); +} + +#[test] +fn an_unregistered_source_profile_cannot_emit_transactions() { + let intake = load_canonical_intake_scenario("complete-multi-role"); + + let serialized = + serde_json::to_value(analyze_software_update_point(&intake)).expect("analysis serializes"); + + assert_eq!( + serialized["extractionProfile"]["selectionState"], + "unavailable" + ); + assert!(serialized["extractionProfile"]["profileId"].is_null()); + assert_eq!(serialized["transactions"], serde_json::json!([])); +} + +#[test] +fn required_coverage_gap_overrides_retry_deferred_classification() { + let (mut manifest, payloads) = raw_scenario("sync-retry"); + let incomplete_manifest: Value = serde_json::from_str( + &std::fs::read_to_string(corpus_root().join("incomplete/manifest.json")) + .expect("incomplete manifest is readable"), + ) + .expect("incomplete manifest is valid JSON"); + let denied = incomplete_manifest["artifacts"][1].clone(); + manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .push(denied); + canonicalize_preparation_manifest(&mut manifest); + + let serialized = serde_json::to_value(analyze_software_update_point( + &assess_prepared_manifest(manifest, &payloads), + )) + .expect("analysis serializes"); + let transaction = &serialized["transactions"][0]; + + assert_eq!(transaction["state"], "incomplete"); + assert_eq!(transaction["classification"], "insufficientEvidence"); + assert_eq!(transaction["confidence"], "low"); + assert_eq!( + transaction["coverageGapArtifactIds"], + serde_json::json!(["incomplete-02-wsync-denied"]) + ); +} + +#[test] +fn coverage_gaps_and_requests_are_scoped_to_the_exact_sup_subject() { + let (mut manifest, payloads) = raw_scenario("sync-success"); + let incomplete_manifest: Value = serde_json::from_str( + &std::fs::read_to_string(corpus_root().join("incomplete/manifest.json")) + .expect("incomplete manifest is readable"), + ) + .expect("incomplete manifest is valid JSON"); + let mut foreign_gap = incomplete_manifest["artifacts"][1].clone(); + foreign_gap["workflowSubjectHandle"] = Value::String("synthetic:subject:sup-01".to_owned()); + manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .push(foreign_gap); + canonicalize_preparation_manifest(&mut manifest); + + let serialized = serde_json::to_value(analyze_software_update_point( + &assess_prepared_manifest(manifest, &payloads), + )) + .expect("analysis serializes"); + let transaction = &serialized["transactions"][0]; + + assert_eq!(transaction["state"], "succeeded"); + assert_eq!(transaction["confidence"], "high"); + assert_eq!(transaction["coverageGapArtifactIds"], serde_json::json!([])); + assert_eq!( + serialized["artifactRequests"], + serde_json::json!([{ + "supHandle": "synthetic:subject:sup-01", + "sourceId": "server-sup-sync", + "reasonCode": "coverageAccessDenied", + }]) + ); +} + +#[test] +fn captured_uninterpreted_wsus_supplement_keeps_the_confidence_ceiling() { + let (mut manifest, mut payloads) = prepared_scenario("supplemental-wsus-skipped"); + let supplemental = manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "supplemental-04-wsus-health") + .expect("supplemental artifact exists"); + let supplement_bytes = br#"{}"#.to_vec(); + supplemental["captureState"] = Value::String("captured".to_owned()); + supplemental["rotation"] = serde_json::json!({ + "kind": "current", + "lineageId": "sup-wsus-health", + }); + supplemental["encoding"] = Value::String("utf-8".to_owned()); + supplemental["collectionLimit"] = + serde_json::json!({ "byteLimit": 4096, "limitApplied": false }); + supplemental["bytesCopied"] = Value::from(supplement_bytes.len()); + supplemental["relativePath"] = Value::String( + "evidence/sccm/server/wsus/server-sup-wsus/subject-software-update-point/current/WsusHealth.json" + .to_owned(), + ); + payloads.push(SccmServerArtifactPayload { + manifest_artifact_id: "supplemental-04-wsus-health".to_owned(), + bytes: supplement_bytes, + }); + + let serialized = serde_json::to_value(analyze_software_update_point( + &assess_prepared_manifest(manifest, &payloads), + )) + .expect("analysis serializes"); + + assert_eq!(serialized["transactions"][0]["state"], "succeeded"); + assert_eq!(serialized["transactions"][0]["confidence"], "medium"); + assert_eq!(serialized["transactions"][0]["confidenceCeiling"], "medium"); +} + +#[test] +fn tied_or_unusable_timestamps_fail_closed_without_artifact_id_chronology() { + let (mut manifest, mut tied_payloads) = prepared_scenario("sync-success"); + let wsync = tied_payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == "sync-success-02-wsync") + .expect("wsync payload exists"); + let content = String::from_utf8(wsync.bytes.clone()).expect("fixture is UTF-8"); + wsync.bytes = content + .replacen("14:01:00.000+000", "14:00:00.000+000", 1) + .into_bytes(); + let tied = + analyze_software_update_point(&assess_prepared_manifest(manifest.clone(), &tied_payloads)); + assert!(tied.transactions.is_empty()); + + let (_, mut unusable_payloads) = prepared_scenario("sync-success"); + let wcm = unusable_payloads + .iter_mut() + .find(|payload| payload.manifest_artifact_id == "sync-success-01-wcm") + .expect("WCM payload exists"); + let content = String::from_utf8(wcm.bytes.clone()).expect("fixture is UTF-8"); + wcm.bytes = content + .replacen("14:00:00.000+000", "14:00:00.000+9999", 1) + .into_bytes(); + let wcm_manifest = manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "sync-success-01-wcm") + .expect("WCM manifest artifact exists"); + wcm_manifest["bytesCopied"] = Value::from(wcm.bytes.len()); + let unusable = + analyze_software_update_point(&assess_prepared_manifest(manifest, &unusable_payloads)); + assert!(unusable.transactions.is_empty()); +} + +#[test] +fn unusable_later_phase_timestamp_poisons_the_exact_sup_transaction() { + let (mut manifest, mut payloads) = prepared_scenario("incomplete"); + let later_bytes = b"\n".to_vec(); + let later_manifest = manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "incomplete-02-wsync-denied") + .expect("later-phase manifest artifact exists"); + later_manifest["captureState"] = Value::String("captured".to_owned()); + later_manifest["encoding"] = Value::String("utf-8".to_owned()); + later_manifest["collectionLimit"] = + serde_json::json!({ "byteLimit": 4096, "limitApplied": false }); + later_manifest["bytesCopied"] = Value::from(later_bytes.len()); + later_manifest["relativePath"] = Value::String( + "evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/current/wsyncmgr.log" + .to_owned(), + ); + payloads.push(SccmServerArtifactPayload { + manifest_artifact_id: "incomplete-02-wsync-denied".to_owned(), + bytes: later_bytes, + }); + + let analysis = analyze_software_update_point(&assess_prepared_manifest(manifest, &payloads)); + + assert!( + analysis.transactions.is_empty(), + "a timestamp-poisoned exact subject cannot retain a correlatable prefix" + ); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_software_update_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point_fixture_contract.rs new file mode 100644 index 000000000..7eaab7da3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point_fixture_contract.rs @@ -0,0 +1,3072 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, NaiveDateTime}; +use cmtraceopen_parser::sccm::{ + normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmEvidence, SccmRole, SccmRotation, + SccmTimeOrderingState, +}; +use serde_json::{json, Value}; + +const SCENARIOS: &[&str] = &[ + "incomplete", + "metadata-failure", + "rotation-boundary", + "sup-setup-failure", + "supplemental-wsus-skipped", + "sync-retry", + "sync-success", + "unrelated-update-key", + "wcm-configuration-failure", + "wsus-health-failure", +]; + +const STATE_CHAIN: &[&str] = &[ + "configure", + "synchronize", + "importOrProcessMetadata", + "validateWsus", + "publishAvailability", + "healthyOrTerminal", +]; + +const EXACT_PROFILE: &str = "sup-server-5.00.test-v1"; +const EXACT_SOURCE_VERSION: &str = "5.00.TEST.0001"; +const EXACT_SITE: &str = "LAB"; +const EXACT_SUP: &str = "safe:sup:lab-sup-01"; +const EXACT_WSUS: &str = "safe:wsus:lab-wsus-01"; +const EXACT_SITE_SERVER: &str = "safe:server:lab-pri-01"; +const EXACT_CLIENT: &str = "safe:client:lab-client-01"; + +fn expected_observation_signature( + scenario: &str, +) -> &'static [(&'static str, &'static str, &'static str, bool)] { + match scenario { + "incomplete" => &[("sync-10-01-configure", "configure", "succeeded", false)], + "metadata-failure" => &[ + ("sync-05-01-configure", "configure", "succeeded", false), + ("sync-05-02-synchronize", "synchronize", "succeeded", false), + ( + "sync-05-03-import", + "importOrProcessMetadata", + "failed", + true, + ), + ], + "rotation-boundary" => &[], + "sup-setup-failure" => &[("sync-06-01-configure", "configure", "failed", true)], + "supplemental-wsus-skipped" => &[ + ("sync-07-01-configure", "configure", "succeeded", false), + ("sync-07-02-synchronize", "synchronize", "succeeded", false), + ( + "sync-07-03-import", + "importOrProcessMetadata", + "succeeded", + false, + ), + ("sync-07-04-validate", "validateWsus", "succeeded", false), + ( + "sync-07-05-publish", + "publishAvailability", + "succeeded", + false, + ), + ( + "sync-07-06-terminal", + "healthyOrTerminal", + "succeeded", + true, + ), + ], + "sync-retry" => &[ + ("sync-04-01-configure", "configure", "succeeded", false), + ("sync-04-02-retry", "synchronize", "retrying", false), + ], + "sync-success" => &[ + ("sync-01-01-configure", "configure", "succeeded", false), + ("sync-01-02-synchronize", "synchronize", "succeeded", false), + ( + "sync-01-03-import", + "importOrProcessMetadata", + "succeeded", + false, + ), + ("sync-01-04-validate", "validateWsus", "succeeded", false), + ( + "sync-01-05-publish", + "publishAvailability", + "succeeded", + false, + ), + ( + "sync-01-06-terminal", + "healthyOrTerminal", + "succeeded", + true, + ), + ], + "unrelated-update-key" => &[ + ("sync-08-01-configure", "configure", "succeeded", false), + ("sync-08-02-synchronize", "synchronize", "succeeded", false), + ( + "sync-08-03-import", + "importOrProcessMetadata", + "succeeded", + false, + ), + ("sync-08-04-validate", "validateWsus", "succeeded", false), + ( + "sync-08-05-publish", + "publishAvailability", + "succeeded", + false, + ), + ( + "sync-08-06-terminal", + "healthyOrTerminal", + "succeeded", + true, + ), + ], + "wcm-configuration-failure" => &[("sync-02-01-configure", "configure", "failed", true)], + "wsus-health-failure" => &[ + ("sync-03-01-configure", "configure", "succeeded", false), + ("sync-03-02-synchronize", "synchronize", "succeeded", false), + ( + "sync-03-03-import", + "importOrProcessMetadata", + "succeeded", + false, + ), + ("sync-03-04-validate", "validateWsus", "failed", true), + ], + _ => &[], + } +} + +fn expected_source_local_signature(scenario: &str) -> &'static [(&'static str, &'static str)] { + match scenario { + "rotation-boundary" => &[ + ("rotation-01-split", "rotationSplit"), + ("rotation-02-malformed", "malformedEvidence"), + ], + "unrelated-update-key" => &[("unrelated-client-01", "ignoredClientEvidence")], + _ => &[], + } +} + +fn corpus_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/software_update_point") +} + +fn mutation_asset_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/software_update_point_mutation_assets") +} + +fn relative_fixture_files(root: &std::path::Path) -> Result, String> { + let mut pending = vec![root.to_path_buf()]; + let mut files = BTreeSet::new(); + while let Some(directory) = pending.pop() { + let entries = std::fs::read_dir(&directory) + .map_err(|error| format!("{} is readable: {error}", directory.display()))?; + for entry in entries { + let path = entry + .map_err(|error| format!("{} has a readable entry: {error}", directory.display()))? + .path(); + if path.is_dir() { + pending.push(path); + } else if path.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|error| { + format!("{} is beneath {}: {error}", path.display(), root.display()) + })? + .to_str() + .ok_or_else(|| format!("{} is valid UTF-8", path.display()))? + .replace(std::path::MAIN_SEPARATOR, "/"); + files.insert(relative); + } + } + } + Ok(files) +} + +fn read_json(scenario: &str, filename: &str) -> Result { + let path = corpus_root().join(scenario).join(filename); + let contents = std::fs::read_to_string(&path) + .map_err(|error| format!("{} is readable: {error}", path.display()))?; + serde_json::from_str(&contents) + .map_err(|error| format!("{} contains valid JSON: {error}", path.display())) +} + +fn required_string<'a>(value: &'a Value, field: &str, context: &str) -> Result<&'a str, String> { + value[field] + .as_str() + .ok_or_else(|| format!("{context}.{field} must be a string")) +} + +fn required_array<'a>(value: &'a Value, field: &str, context: &str) -> Result<&'a [Value], String> { + value[field] + .as_array() + .map(Vec::as_slice) + .ok_or_else(|| format!("{context}.{field} must be an array")) +} + +fn required_bool(value: &Value, field: &str, context: &str) -> Result { + value[field] + .as_bool() + .ok_or_else(|| format!("{context}.{field} must be a boolean")) +} + +fn index_by_identifier(array: &Value, field: &str, identifier: &str, context: &str) -> usize { + array + .as_array() + .unwrap_or_else(|| panic!("{context} is an array")) + .iter() + .position(|value| value[field] == identifier) + .unwrap_or_else(|| panic!("{context} contains {field}={identifier}")) +} + +fn artifact_index(manifest: &Value, artifact_id: &str) -> usize { + index_by_identifier( + &manifest["artifacts"], + "artifactId", + artifact_id, + "manifest.artifacts", + ) +} + +fn transaction_index(expected: &Value, transaction_id: &str) -> usize { + index_by_identifier( + &expected["transactions"], + "transactionId", + transaction_id, + "expected.transactions", + ) +} + +fn observation_index(expected: &Value, transaction_id: &str, observation_id: &str) -> usize { + let transaction = transaction_index(expected, transaction_id); + index_by_identifier( + &expected["transactions"][transaction]["observations"], + "observationId", + observation_id, + "transaction.observations", + ) +} + +fn source_local_index(expected: &Value, observation_id: &str) -> usize { + index_by_identifier( + &expected["sourceLocalObservations"], + "observationId", + observation_id, + "expected.sourceLocalObservations", + ) +} + +fn reject_unknown_fields( + value: &Value, + allowed: &[&str], + context: &str, + failures: &mut Vec, +) { + let Some(object) = value.as_object() else { + failures.push(format!("{context} must be an object")); + return; + }; + for field in object.keys() { + if !allowed.contains(&field.as_str()) { + failures.push(format!("{context} contains unsupported field {field}")); + } + } +} + +fn role_from_manifest(role: &str) -> Result { + match role { + "client" => Ok(SccmRole::Client), + "siteServer" => Ok(SccmRole::SiteServer), + "softwareUpdatePoint" => Ok(SccmRole::SoftwareUpdatePoint), + "wsUs" => Ok(SccmRole::WsUs), + other => Err(format!("unsupported fixture producer role {other}")), + } +} + +fn coverage_from_manifest(state: &str) -> Result { + match state { + "captured" => Ok(SccmCoverageState::Captured), + "absent" => Ok(SccmCoverageState::Absent), + "accessDenied" => Ok(SccmCoverageState::AccessDenied), + "capped" => Ok(SccmCoverageState::Capped), + "skipped" => Ok(SccmCoverageState::Skipped), + "unsupported" => Ok(SccmCoverageState::Unsupported), + "parseFailed" => Ok(SccmCoverageState::ParseFailed), + other => Err(format!("unsupported fixture capture state {other}")), + } +} + +fn rotation_from_manifest(rotation: &Value) -> Result { + match required_string(rotation, "kind", "rotation")? { + "current" => Ok(SccmRotation::Current), + "lo_" => Ok(SccmRotation::LoUnderscore), + "numbered" => rotation["value"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value != 0) + .map(SccmRotation::Numbered) + .ok_or_else(|| "numbered rotation requires a nonzero u32 value".to_owned()), + "timestamped" => { + let value = required_string(rotation, "value", "rotation")?; + if value.len() == "YYYYMMDD-HHMMSS".len() + && NaiveDateTime::parse_from_str(value, "%Y%m%d-%H%M%S") + .is_ok_and(|timestamp| timestamp.format("%Y%m%d-%H%M%S").to_string() == value) + { + Ok(SccmRotation::Timestamped(value.to_owned())) + } else { + Err("timestamped rotation requires canonical YYYYMMDD-HHMMSS".to_owned()) + } + } + other => Err(format!("unsupported fixture rotation {other}")), + } +} + +fn allowed_source(source_id: &str, role: &str, basename: &str, source_kind: &str) -> bool { + matches!( + (source_id, role, basename, source_kind), + ("server-sup-sync", "siteServer", "WCM.log", "ccmLog") + | ("server-sup-sync", "siteServer", "wsyncmgr.log", "ccmLog") + | ( + "server-sup-sync", + "softwareUpdatePoint", + "SUPSetup.log", + "ccmLog" + ) + | ( + "server-sup-sync", + "softwareUpdatePoint", + "WSUSCtrl.log", + "ccmLog" + ) + | ( + "server-sup-wsus", + "wsUs", + "WsusHealth.json", + "profileDefined" + ) + | ( + "client-updates-control", + "client", + "WUAHandler.log", + "ccmLog" + ) + ) +} + +fn phase_allowed_for_artifact(artifact: &ParsedArtifact, phase: &str) -> bool { + matches!( + (artifact.basename.as_str(), phase), + ("WCM.log", "configure") + | ( + "wsyncmgr.log", + "synchronize" + | "importOrProcessMetadata" + | "publishAvailability" + | "healthyOrTerminal" + ) + | ("SUPSetup.log", "configure" | "healthyOrTerminal") + | ("WSUSCtrl.log", "validateWsus" | "healthyOrTerminal") + | ("WsusHealth.json", "validateWsus" | "healthyOrTerminal") + ) +} + +fn parse_fixture_fields(message: &str) -> Result, String> { + let message = message + .strip_prefix("[sccm-public-message-v1] ") + .ok_or_else(|| "normalized evidence lacks the public projection profile".to_owned())?; + let mut segments = message.split(';').map(str::trim); + if segments.next() != Some("SYNTHETIC FIXTURE") { + return Err("CCM evidence lacks the semantic SYNTHETIC FIXTURE marker".to_owned()); + } + + let allowed = [ + "Phase", + "Disposition", + "Terminal", + "SyncRunId", + "SiteCode", + "SupHandle", + "ProfileId", + "UpdateId", + "KbId", + "ClientHandle", + ]; + let mut fields = BTreeMap::new(); + for segment in segments { + let (name, value) = segment + .split_once('=') + .ok_or_else(|| format!("fixture field is not Name=Value: {segment}"))?; + if !allowed.contains(&name) { + return Err(format!("unsupported fixture field {name}")); + } + if value.is_empty() { + return Err(format!("fixture field {name} is empty")); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-')) + { + return Err(format!("fixture field {name} contains unsupported syntax")); + } + if fields.insert(name.to_owned(), value.to_owned()).is_some() { + return Err(format!("duplicate fixture field {name}")); + } + } + Ok(fields) +} + +fn source_path_is_bounded(relative_path: &str, basename: &str) -> bool { + !relative_path.is_empty() + && relative_path.starts_with("evidence/") + && !relative_path.starts_with('/') + && !relative_path.contains('\\') + && relative_path.split('/').all(|segment| { + !segment.is_empty() + && !matches!(segment, "." | "..") + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) + && relative_path + .rsplit('/') + .next() + .is_some_and(|candidate| candidate == basename) +} + +fn sanitized_source_path_is_safe(value: &str) -> bool { + value.strip_prefix("SYNTHETIC://").is_some_and(|suffix| { + !suffix.is_empty() + && !suffix.contains('\\') + && suffix.split('/').all(|segment| { + !segment.is_empty() + && !matches!(segment, "." | "..") + && segment.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-') + }) + }) + }) +} + +fn rotation_source_basename(basename: &str, rotation: &SccmRotation) -> Option { + match rotation { + SccmRotation::Current => Some(basename.to_owned()), + SccmRotation::LoUnderscore => basename + .strip_suffix(".log") + .map(|stem| format!("{stem}.lo_")), + SccmRotation::Numbered(value) => basename + .ends_with(".log") + .then(|| format!("{basename}.{value}")), + SccmRotation::Timestamped(value) => basename + .ends_with(".log") + .then(|| format!("{basename}.{value}")), + SccmRotation::Unknown(_) => None, + } +} + +fn rotation_destination_segment(rotation: &SccmRotation) -> Option { + match rotation { + SccmRotation::Current => Some("current".to_owned()), + SccmRotation::LoUnderscore => Some("lo_".to_owned()), + SccmRotation::Numbered(value) => Some(format!("numbered-{value}")), + SccmRotation::Timestamped(value) => Some(format!("timestamped-{value}")), + SccmRotation::Unknown(_) => None, + } +} + +fn bounded_token_is_safe(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && value + .bytes() + .next_back() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn prefixed_token_is_nonempty(value: &str, prefix: &str) -> bool { + value.strip_prefix(prefix).is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) +} + +#[derive(Debug)] +struct ParsedArtifact { + state: String, + source_id: String, + role: String, + producer_host: String, + workflow_subject_role: String, + workflow_subject_handle: String, + source_version: String, + basename: String, + rotation_kind: String, + rotation_lineage: String, + fragment_complete: Option, +} + +#[derive(Debug)] +struct ParsedScenario { + artifacts: BTreeMap, + evidence: BTreeMap<(String, u32, u32), SccmEvidence>, +} + +fn validate_manifest( + scenario: &str, + scenario_root: &std::path::Path, + manifest: &Value, + fixture_overrides: &BTreeMap>, +) -> Result> { + let mut failures = Vec::new(); + reject_unknown_fields( + manifest, + &[ + "sccmManifestVersion", + "proposalOnly", + "syntheticFixture", + "scenario", + "bundle", + "topology", + "artifacts", + ], + "manifest", + &mut failures, + ); + reject_unknown_fields( + &manifest["bundle"], + &["bundleRole", "workflow", "capturedUtc"], + "bundle", + &mut failures, + ); + reject_unknown_fields( + &manifest["topology"], + &["siteCode", "supHandle", "wsusHandle", "rolesObserved"], + "topology", + &mut failures, + ); + if manifest["sccmManifestVersion"] != 1 + || manifest["proposalOnly"] != true + || manifest["syntheticFixture"] != true + || manifest["scenario"] != scenario + || manifest["bundle"]["bundleRole"] != "server" + || manifest["bundle"]["workflow"] != "softwareUpdatePoint" + { + failures + .push("manifest does not retain the versioned synthetic server boundary".to_owned()); + } + if manifest["topology"]["siteCode"] != EXACT_SITE + || manifest["topology"]["supHandle"] != EXACT_SUP + || manifest["topology"]["wsusHandle"] != EXACT_WSUS + { + failures.push("manifest topology is not the exact synthetic LAB SUP/WSUS scope".to_owned()); + } + + let role_values = manifest["topology"]["rolesObserved"].as_array(); + let roles = role_values + .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .unwrap_or_default(); + let mut sorted_roles = roles.clone(); + sorted_roles.sort_unstable(); + sorted_roles.dedup(); + if role_values.is_none_or(|values| values.len() != roles.len()) + || roles != sorted_roles + || !roles.contains(&"siteServer") + || !roles.contains(&"softwareUpdatePoint") + || roles + .iter() + .any(|role| !matches!(*role, "siteServer" | "softwareUpdatePoint" | "wsUs")) + { + failures.push( + "rolesObserved must be sorted, unique, catalogued, and retain site/SUP observations" + .to_owned(), + ); + } + + let captured_utc = + match required_string(&manifest["bundle"], "capturedUtc", "bundle").and_then(|value| { + DateTime::parse_from_rfc3339(value) + .map(|parsed| parsed.timestamp_millis()) + .map_err(|error| format!("bundle.capturedUtc is RFC3339: {error}")) + }) { + Ok(value) => value, + Err(error) => { + failures.push(error); + i64::MAX + } + }; + + let artifacts = match required_array(manifest, "artifacts", "manifest") { + Ok(artifacts) => artifacts, + Err(error) => { + failures.push(error); + return Err(failures); + } + }; + let artifact_order = artifacts + .iter() + .filter_map(|artifact| artifact["artifactId"].as_str()) + .collect::>(); + let mut sorted_artifact_order = artifact_order.clone(); + sorted_artifact_order.sort_unstable(); + if artifact_order != sorted_artifact_order { + failures.push("manifest artifacts are not sorted by artifactId".to_owned()); + } + + let mut parsed_artifacts = BTreeMap::new(); + let mut evidence_by_reference = BTreeMap::new(); + let mut relative_paths = BTreeSet::new(); + let mut physical_identities = BTreeSet::new(); + let mut path_fingerprints = BTreeSet::new(); + for artifact in artifacts { + let artifact_id = match required_string(artifact, "artifactId", "artifact") { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + let context = format!("artifact {artifact_id}"); + reject_unknown_fields( + artifact, + &[ + "artifactId", + "sourceId", + "producerRole", + "producerHostHandle", + "workflowSubjectRole", + "workflowSubjectHandle", + "sourceKind", + "originalBasename", + "sanitizedSourcePath", + "pathFingerprint", + "rotation", + "captureState", + "sourceVersion", + "collectedUtc", + "encoding", + "collectionLimit", + "bytesCopied", + "relativePath", + ], + &context, + &mut failures, + ); + reject_unknown_fields( + &artifact["rotation"], + &["kind", "value", "lineageId", "fragmentComplete"], + &format!("{context}.rotation"), + &mut failures, + ); + if artifact.get("collectionLimit").is_some() { + reject_unknown_fields( + &artifact["collectionLimit"], + &["byteLimit", "limitApplied"], + &format!("{context}.collectionLimit"), + &mut failures, + ); + } + + let source_id = required_string(artifact, "sourceId", &context).unwrap_or("invalid"); + let role = required_string(artifact, "producerRole", &context).unwrap_or("invalid"); + let producer_host = + required_string(artifact, "producerHostHandle", &context).unwrap_or("invalid"); + let workflow_subject_role = + required_string(artifact, "workflowSubjectRole", &context).unwrap_or("invalid"); + let workflow_subject_handle = + required_string(artifact, "workflowSubjectHandle", &context).unwrap_or("invalid"); + let basename = required_string(artifact, "originalBasename", &context).unwrap_or("invalid"); + let source_kind = required_string(artifact, "sourceKind", &context).unwrap_or("invalid"); + let state = required_string(artifact, "captureState", &context).unwrap_or("invalid"); + let source_version = + required_string(artifact, "sourceVersion", &context).unwrap_or("invalid"); + if !allowed_source(source_id, role, basename, source_kind) { + failures.push(format!( + "{artifact_id} has an uncatalogued source/producer/basename/grammar tuple" + )); + } + if workflow_subject_role != "softwareUpdatePoint" || workflow_subject_handle != EXACT_SUP { + failures.push(format!( + "{artifact_id} loses the exact SUP workflow subject" + )); + } + let expected_producer = match role { + "siteServer" => Some(EXACT_SITE_SERVER), + "softwareUpdatePoint" => Some(EXACT_SUP), + "wsUs" => Some(EXACT_WSUS), + "client" => Some(EXACT_CLIENT), + _ => None, + }; + if Some(producer_host) != expected_producer { + failures.push(format!( + "{artifact_id} producer handle is not exact for its declared role" + )); + } + let path_fingerprint = artifact["pathFingerprint"].as_str(); + if !path_fingerprint.is_some_and(|value| prefixed_token_is_nonempty(value, "synthetic:")) + || !artifact["sanitizedSourcePath"] + .as_str() + .is_some_and(sanitized_source_path_is_safe) + { + failures.push(format!("{artifact_id} leaks or omits path provenance")); + } + if path_fingerprint + .map(str::to_ascii_lowercase) + .is_some_and(|value| !path_fingerprints.insert(value)) + { + failures.push(format!("{artifact_id} reuses a physical path fingerprint")); + } + if source_version != EXACT_SOURCE_VERSION { + failures.push(format!( + "{artifact_id} is outside the selected synthetic version profile" + )); + } + + let rotation_kind = artifact["rotation"]["kind"] + .as_str() + .unwrap_or("invalid") + .to_owned(); + let rotation_lineage = artifact["rotation"]["lineageId"] + .as_str() + .unwrap_or_default() + .to_owned(); + let rotation_value_shape_valid = match rotation_kind.as_str() { + "current" | "lo_" => artifact["rotation"].get("value").is_none(), + "numbered" => artifact["rotation"]["value"].as_u64().is_some(), + "timestamped" => artifact["rotation"]["value"].as_str().is_some(), + _ => false, + }; + let rotation_model = rotation_from_manifest(&artifact["rotation"]); + if !bounded_token_is_safe(&rotation_lineage) + || !rotation_value_shape_valid + || rotation_model.is_err() + { + failures.push(format!( + "{artifact_id} has incomplete or incoherent rotation provenance" + )); + } + if let Ok(rotation) = &rotation_model { + let source_basename = artifact["sanitizedSourcePath"] + .as_str() + .and_then(|value| value.rsplit('/').next()); + if rotation_source_basename(basename, rotation).as_deref() != source_basename { + failures.push(format!( + "{artifact_id} rotation is not bound to its sanitized source path" + )); + } + } + let identity = ( + artifact["producerHostHandle"] + .as_str() + .unwrap_or_default() + .to_owned(), + artifact["sanitizedSourcePath"] + .as_str() + .unwrap_or_default() + .to_ascii_lowercase(), + ); + if !physical_identities.insert(identity) { + failures.push(format!( + "{artifact_id} duplicates one physical source identity" + )); + } + + let artifact_collected_utc = match required_string(artifact, "collectedUtc", &context) + .and_then(|value| { + DateTime::parse_from_rfc3339(value) + .map(|parsed| parsed.timestamp_millis()) + .map_err(|error| format!("{context}.collectedUtc is RFC3339: {error}")) + }) { + Ok(value) if value <= captured_utc => Some(value), + Ok(_) => { + failures.push(format!("{artifact_id} was collected after its bundle")); + None + } + Err(error) => { + failures.push(error); + None + } + }; + + let role_model = match role_from_manifest(role) { + Ok(value) => value, + Err(error) => { + failures.push(format!("{artifact_id}: {error}")); + continue; + } + }; + let coverage_model = match coverage_from_manifest(state) { + Ok(value) => value, + Err(error) => { + failures.push(format!("{artifact_id}: {error}")); + continue; + } + }; + let rotation_model = match rotation_model { + Ok(value) => value, + Err(error) => { + failures.push(format!("{artifact_id}: {error}")); + continue; + } + }; + + if matches!(state, "captured" | "capped" | "parseFailed") { + if artifact["rotation"]["fragmentComplete"].as_bool().is_none() { + failures.push(format!( + "{artifact_id} physical capture lacks fragment completeness" + )); + } + let relative_path = match required_string(artifact, "relativePath", &context) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + if !source_path_is_bounded(relative_path, basename) { + failures.push(format!( + "{artifact_id} has an unsafe or mismatched evidence path" + )); + } + if rotation_destination_segment(&rotation_model).as_deref() + != relative_path.rsplit('/').nth(1) + { + failures.push(format!( + "{artifact_id} rotation is not bound to its evidence destination" + )); + } + if !relative_paths.insert(relative_path.to_ascii_lowercase()) { + failures.push(format!( + "{artifact_id} collides with an evidence destination" + )); + } + let fixture_path = scenario_root.join(relative_path); + let bytes = match fixture_overrides.get(relative_path) { + Some(value) => value.clone(), + None => match std::fs::read(&fixture_path) { + Ok(value) => value, + Err(error) => { + failures.push(format!( + "{} is readable for {artifact_id}: {error}", + fixture_path.display() + )); + continue; + } + }, + }; + if artifact["bytesCopied"].as_u64() != Some(bytes.len() as u64) { + failures.push(format!( + "{artifact_id}.bytesCopied does not match its physical fixture" + )); + } + let byte_limit = artifact["collectionLimit"]["byteLimit"].as_u64(); + let limit_applied = artifact["collectionLimit"]["limitApplied"].as_bool(); + if artifact["encoding"] != "utf-8" + || byte_limit.is_none() + || limit_applied.is_none() + || state == "capped" + && (limit_applied != Some(true) || byte_limit != Some(bytes.len() as u64)) + || state != "capped" + && (limit_applied != Some(false) + || byte_limit.is_some_and(|limit| limit < bytes.len() as u64)) + { + failures.push(format!( + "{artifact_id} has incoherent raw-byte collection provenance" + )); + } + if !String::from_utf8_lossy(&bytes).contains("SYNTHETIC FIXTURE") { + failures.push(format!("{artifact_id} lacks a synthetic fixture marker")); + } + + if source_kind == "ccmLog" { + let content = String::from_utf8_lossy(&bytes); + let artifact_model = SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: basename.to_owned(), + original_path: None, + host: artifact["producerHostHandle"].as_str().map(str::to_owned), + role: role_model.clone(), + configmgr_version: artifact["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: artifact["collectedUtc"].as_str().map(str::to_owned), + rotation: rotation_model, + coverage: coverage_model, + encoding: artifact["encoding"].as_str().map(str::to_owned), + }; + let normalized = normalize_ccm_artifact(artifact_model, &content); + if state == "parseFailed" { + if !normalized.is_empty() { + failures.push(format!( + "{artifact_id} is parseFailed but contains usable normalized CCM evidence" + )); + } + } else { + if artifact["rotation"]["fragmentComplete"] == false && !normalized.is_empty() { + failures.push(format!( + "{artifact_id} exposes a logical record from an incomplete fragment" + )); + } + for record in normalized { + if record.role != role_model { + failures.push(format!("{artifact_id} loses producer-role provenance")); + } + if record.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + || record.timestamp.offset_minutes != Some(0) + || record.timestamp.utc_millis.is_none() + || artifact_collected_utc.is_none() + || record + .timestamp + .utc_millis + .zip(artifact_collected_utc) + .is_some_and(|(evidence_utc, collected_utc)| { + evidence_utc > collected_utc + }) + { + failures.push(format!( + "{artifact_id} has unusable evidence/artifact/capture chronology" + )); + } + if record + .ccm_source_file + .as_deref() + .is_none_or(|value| !value.contains(".cpp:")) + { + failures.push(format!( + "{artifact_id} loses distinct CCM code-origin provenance" + )); + } + match parse_fixture_fields(&record.message) { + Ok(fields) => { + if fields.get("SupHandle").map(String::as_str) != Some(EXACT_SUP) { + failures.push(format!( + "{artifact_id} record escapes the exact SUP subject" + )); + } + } + Err(error) => failures.push(format!("{artifact_id}: {error}")), + } + let Some(line_start) = record.reference.line_start else { + failures.push(format!("{artifact_id} evidence lacks lineStart")); + continue; + }; + let Some(line_end) = record.reference.line_end else { + failures.push(format!("{artifact_id} evidence lacks lineEnd")); + continue; + }; + let key = (artifact_id.to_owned(), line_start, line_end); + if evidence_by_reference.insert(key, record).is_some() { + failures + .push(format!("{artifact_id} has duplicate line-range evidence")); + } + } + } + } + } else if artifact.get("relativePath").is_some() + || artifact.get("bytesCopied").is_some() + || artifact.get("encoding").is_some() + || artifact.get("collectionLimit").is_some() + || artifact["rotation"].get("fragmentComplete").is_some() + { + failures.push(format!( + "{artifact_id} invents physical capture facts for state {state}" + )); + } + + if parsed_artifacts + .insert( + artifact_id.to_owned(), + ParsedArtifact { + state: state.to_owned(), + source_id: source_id.to_owned(), + role: role.to_owned(), + producer_host: producer_host.to_owned(), + workflow_subject_role: workflow_subject_role.to_owned(), + workflow_subject_handle: workflow_subject_handle.to_owned(), + source_version: source_version.to_owned(), + basename: basename.to_owned(), + rotation_kind, + rotation_lineage, + fragment_complete: artifact["rotation"]["fragmentComplete"].as_bool(), + }, + ) + .is_some() + { + failures.push(format!("duplicate artifactId {artifact_id}")); + } + } + + if failures.is_empty() { + Ok(ParsedScenario { + artifacts: parsed_artifacts, + evidence: evidence_by_reference, + }) + } else { + Err(failures) + } +} + +fn evidence_for<'a>( + parsed: &'a ParsedScenario, + reference: &Value, + context: &str, +) -> Result<&'a SccmEvidence, String> { + let artifact_id = required_string(reference, "artifactId", context)?; + let line_start = reference["startLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| format!("{context}.startLine must be a u32"))?; + let line_end = reference["endLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| format!("{context}.endLine must be a u32"))?; + parsed + .evidence + .get(&(artifact_id.to_owned(), line_start, line_end)) + .ok_or_else(|| { + format!( + "{context} does not cite a physical logical record: {artifact_id}:{line_start}-{line_end}" + ) + }) +} + +fn exact_key_fields(key: &Value, context: &str) -> Result, String> { + let mut fields = BTreeMap::from([ + ( + "SyncRunId", + required_string(key, "syncRunId", context)?.to_owned(), + ), + ( + "SiteCode", + required_string(key, "siteCode", context)?.to_owned(), + ), + ( + "SupHandle", + required_string(key, "supHandle", context)?.to_owned(), + ), + ( + "ProfileId", + required_string(key, "extractionProfileId", context)?.to_owned(), + ), + ]); + match (key["updateId"].as_str(), key["kbId"].as_str()) { + (Some(update_id), Some(kb_id)) => { + fields.insert("UpdateId", update_id.to_owned()); + fields.insert("KbId", kb_id.to_owned()); + } + (None, None) if key["updateId"].is_null() && key["kbId"].is_null() => {} + _ => return Err(format!("{context} has a partial update/KB identity")), + } + if fields["SiteCode"] != EXACT_SITE + || fields["SupHandle"] != EXACT_SUP + || fields["ProfileId"] != EXACT_PROFILE + || key["confidence"] != "exact" + { + return Err(format!("{context} is outside the exact synthetic profile")); + } + Ok(fields) +} + +fn validate_expected( + scenario: &str, + manifest: &Value, + expected: &Value, + parsed: &ParsedScenario, +) -> Result<(), Vec> { + let mut failures = Vec::new(); + reject_unknown_fields( + expected, + &[ + "contractState", + "workflow", + "scenario", + "stateChain", + "analysisContract", + "extractionProfile", + "roleAssessment", + "coverage", + "transactions", + "sourceLocalObservations", + "artifactRequests", + "clientCausalClaims", + "correlationHandoff", + ], + "expected", + &mut failures, + ); + for (value, allowed, context) in [ + ( + &expected["analysisContract"], + &[ + "independentReducer", + "consumesClientOutput", + "crossSideCorrelationPerformed", + ][..], + "analysisContract", + ), + ( + &expected["extractionProfile"], + &["selectionState", "profileId", "validatedRole"][..], + "extractionProfile", + ), + ( + &expected["roleAssessment"], + &[ + "softwareUpdatePointObserved", + "roleAbsentInferred", + "missingDefaultPathInterpretation", + ][..], + "roleAssessment", + ), + ( + &expected["correlationHandoff"], + &["issue", "performed", "timeOnlyEligible"][..], + "correlationHandoff", + ), + ] { + reject_unknown_fields(value, allowed, context, &mut failures); + } + if expected["contractState"] != "proposedPendingReviewed318And335" + || expected["workflow"] != "softwareUpdatePoint" + || expected["scenario"] != scenario + || expected["analysisContract"]["independentReducer"] != true + || expected["analysisContract"]["consumesClientOutput"] != false + || expected["analysisContract"]["crossSideCorrelationPerformed"] != false + { + failures.push("expected output loses the preparation/dependency boundary".to_owned()); + } + let state_values = expected["stateChain"].as_array(); + let state_chain = state_values + .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .unwrap_or_default(); + if state_values.is_none_or(|values| values.len() != state_chain.len()) + || state_chain != STATE_CHAIN + { + failures.push("expected state chain does not match the #330 contract".to_owned()); + } + if expected["extractionProfile"]["profileId"] != EXACT_PROFILE + || expected["extractionProfile"]["selectionState"] != "selectedSynthetic" + || expected["extractionProfile"]["validatedRole"] != "softwareUpdatePoint" + || expected["roleAssessment"]["roleAbsentInferred"] != false + || expected["roleAssessment"]["missingDefaultPathInterpretation"] != "sourceCoverageOnly" + { + failures.push("expected output loses profile or conservative role semantics".to_owned()); + } + + let expected_coverage = parsed + .artifacts + .iter() + .map(|(artifact_id, artifact)| (artifact_id.clone(), artifact.state.clone())) + .collect::>(); + let mut declared_coverage = BTreeMap::new(); + let mut coverage_order = Vec::new(); + match required_array(expected, "coverage", "expected") { + Ok(rows) => { + for row in rows { + reject_unknown_fields(row, &["artifactId", "state"], "coverage", &mut failures); + let artifact_id = + required_string(row, "artifactId", "coverage").unwrap_or("invalid"); + let state = required_string(row, "state", "coverage").unwrap_or("invalid"); + coverage_order.push(artifact_id); + if declared_coverage + .insert(artifact_id.to_owned(), state.to_owned()) + .is_some() + { + failures.push(format!("duplicate coverage row {artifact_id}")); + } + } + } + Err(error) => failures.push(error), + } + let mut sorted_coverage = coverage_order.clone(); + sorted_coverage.sort_unstable(); + if coverage_order != sorted_coverage || declared_coverage != expected_coverage { + failures.push("coverage is not the exact sorted manifest projection".to_owned()); + } + + let transactions = match required_array(expected, "transactions", "expected") { + Ok(value) => value, + Err(error) => { + failures.push(error); + &[] + } + }; + let transaction_order = transactions + .iter() + .filter_map(|transaction| transaction["transactionId"].as_str()) + .collect::>(); + let mut sorted_transaction_order = transaction_order.clone(); + sorted_transaction_order.sort_unstable(); + if transaction_order != sorted_transaction_order { + failures.push("transactions are not deterministically sorted".to_owned()); + } + + let mut seen_transaction_ids = BTreeSet::new(); + let mut seen_transaction_keys = BTreeSet::new(); + for transaction in transactions { + let transaction_id = + required_string(transaction, "transactionId", "transaction").unwrap_or("invalid"); + if !seen_transaction_ids.insert(transaction_id) { + failures.push(format!("duplicate transactionId {transaction_id}")); + } + reject_unknown_fields( + transaction, + &[ + "transactionId", + "key", + "topologyCompatibility", + "correlationEligible", + "state", + "classification", + "confidence", + "confidenceCeiling", + "lastSuccessfulPhase", + "nextSourceId", + "coverageGapArtifactIds", + "observations", + ], + transaction_id, + &mut failures, + ); + reject_unknown_fields( + &transaction["key"], + &[ + "syncRunId", + "siteCode", + "supHandle", + "updateId", + "kbId", + "confidence", + "extractionProfileId", + ], + &format!("{transaction_id}.key"), + &mut failures, + ); + let key_fields = match exact_key_fields(&transaction["key"], transaction_id) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + let expected_id = if let Some(update_id) = key_fields.get("UpdateId") { + format!( + "sup:{}:{}:{}:{}", + key_fields["SyncRunId"], key_fields["SiteCode"], key_fields["SupHandle"], update_id + ) + } else { + format!( + "sup:{}:{}:{}", + key_fields["SyncRunId"], key_fields["SiteCode"], key_fields["SupHandle"] + ) + }; + if transaction_id != expected_id || !seen_transaction_keys.insert(expected_id) { + failures.push(format!( + "{transaction_id} is not unique and derived from its exact immutable key" + )); + } + if transaction["topologyCompatibility"] != "exact" + || transaction["correlationEligible"] != true + { + failures.push(format!("{transaction_id} is not exact/topology-gated")); + } + + let observations = match required_array(transaction, "observations", transaction_id) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + let actual_signature = observations + .iter() + .map(|observation| { + ( + observation["observationId"].as_str(), + observation["phase"].as_str(), + observation["disposition"].as_str(), + observation["terminal"].as_bool(), + ) + }) + .collect::>(); + let expected_signature = expected_observation_signature(scenario) + .iter() + .map(|(observation_id, phase, disposition, terminal)| { + ( + Some(*observation_id), + Some(*phase), + Some(*disposition), + Some(*terminal), + ) + }) + .collect::>(); + if actual_signature != expected_signature { + failures.push(format!( + "{scenario} does not retain its exact required observation chain" + )); + } + let observation_order = observations + .iter() + .filter_map(|observation| observation["observationId"].as_str()) + .collect::>(); + let mut sorted_observation_order = observation_order.clone(); + sorted_observation_order.sort_unstable(); + if observation_order != sorted_observation_order { + failures.push(format!("{transaction_id} observations are not sorted")); + } + + let mut latest_success = None; + let mut terminal_success = false; + let mut terminal_failure = false; + let mut deferred_seen = false; + let mut previous_utc = i64::MIN; + let mut previous_phase = 0usize; + let mut seen_observation_ids = BTreeSet::new(); + let mut seen_transaction_evidence = BTreeSet::new(); + for observation in observations { + let observation_id = + required_string(observation, "observationId", transaction_id).unwrap_or("invalid"); + if !seen_observation_ids.insert(observation_id) { + failures.push(format!("duplicate observationId {observation_id}")); + } + let phase = required_string(observation, "phase", observation_id).unwrap_or("invalid"); + let disposition = + required_string(observation, "disposition", observation_id).unwrap_or("invalid"); + let terminal = required_bool(observation, "terminal", observation_id).unwrap_or(false); + reject_unknown_fields( + observation, + &[ + "observationId", + "phase", + "disposition", + "terminal", + "evidence", + ], + observation_id, + &mut failures, + ); + let phase_index = STATE_CHAIN.iter().position(|candidate| *candidate == phase); + if phase_index.is_none() || phase_index.is_some_and(|index| index < previous_phase) { + failures.push(format!( + "{transaction_id} has an unsupported/backward phase" + )); + } + if let Some(index) = phase_index { + previous_phase = index; + } + let references = match required_array(observation, "evidence", observation_id) { + Ok(value) if !value.is_empty() => value, + Ok(_) => { + failures.push(format!("{observation_id} has no cited evidence")); + continue; + } + Err(error) => { + failures.push(error); + continue; + } + }; + for reference in references { + reject_unknown_fields( + reference, + &["artifactId", "startLine", "endLine"], + &format!("{observation_id}.evidence"), + &mut failures, + ); + let artifact_id = + required_string(reference, "artifactId", observation_id).unwrap_or("invalid"); + match parsed.artifacts.get(artifact_id) { + Some(artifact) + if artifact.role != "client" + && phase_allowed_for_artifact(artifact, phase) => {} + _ => failures.push(format!( + "{observation_id} cites an artifact that cannot own phase {phase}" + )), + } + let record = match evidence_for(parsed, reference, observation_id) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + let evidence_identity = ( + artifact_id.to_owned(), + reference["startLine"].as_u64(), + reference["endLine"].as_u64(), + ); + if !seen_transaction_evidence.insert(evidence_identity) { + failures.push(format!( + "{transaction_id} cites one physical logical record more than once" + )); + } + let fields = match parse_fixture_fields(&record.message) { + Ok(value) => value, + Err(error) => { + failures.push(format!("{observation_id}: {error}")); + continue; + } + }; + for (field, expected_value) in &key_fields { + if fields.get(*field) != Some(expected_value) { + failures.push(format!( + "{observation_id} evidence does not repeat exact {field}" + )); + } + } + for optional in ["UpdateId", "KbId"] { + if !key_fields.contains_key(optional) && fields.contains_key(optional) { + failures.push(format!( + "{observation_id} invents an unkeyed {optional} identity" + )); + } + } + if fields.get("Phase").map(String::as_str) != Some(phase) + || fields.get("Disposition").map(String::as_str) != Some(disposition) + || fields.get("Terminal").map(String::as_str) + != Some(if terminal { "true" } else { "false" }) + { + failures.push(format!( + "{observation_id} phase/disposition/terminal is not cited exactly" + )); + } + let utc = record.timestamp.utc_millis.unwrap_or(i64::MIN); + if utc < previous_utc { + failures.push(format!("{transaction_id} evidence is not UTC-ordered")); + } + previous_utc = utc; + } + match (disposition, terminal) { + ("succeeded", true) => { + latest_success = latest_success.max(phase_index); + terminal_success = true; + } + ("succeeded", false) => latest_success = latest_success.max(phase_index), + ("failed", true) => terminal_failure = true, + ("deferred" | "retrying", false) => deferred_seen = true, + _ => failures.push(format!( + "{observation_id} uses an incoherent disposition/terminal pair" + )), + } + } + + let computed_last_success = latest_success.map(|index| STATE_CHAIN[index]); + if transaction["lastSuccessfulPhase"].as_str() != computed_last_success + || computed_last_success.is_none() && !transaction["lastSuccessfulPhase"].is_null() + { + failures.push(format!( + "{transaction_id}.lastSuccessfulPhase is not evidence-derived" + )); + } + let state = required_string(transaction, "state", transaction_id).unwrap_or("invalid"); + let classification = + required_string(transaction, "classification", transaction_id).unwrap_or("invalid"); + let confidence = + required_string(transaction, "confidence", transaction_id).unwrap_or("invalid"); + let confidence_ceiling = + required_string(transaction, "confidenceCeiling", transaction_id).unwrap_or("invalid"); + + let gap_values = transaction["coverageGapArtifactIds"].as_array(); + let gap_ids = gap_values + .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .unwrap_or_default(); + let mut sorted_gap_ids = gap_ids.clone(); + sorted_gap_ids.sort_unstable(); + sorted_gap_ids.dedup(); + if gap_values.is_none_or(|values| values.len() != gap_ids.len()) + || gap_ids != sorted_gap_ids + { + failures.push(format!( + "{transaction_id} coverage gaps are not exact sorted strings" + )); + } + let expected_gap_ids = parsed + .artifacts + .iter() + .filter(|(_, artifact)| { + artifact.role != "client" + && (artifact.state != "captured" || artifact.fragment_complete != Some(true)) + }) + .map(|(artifact_id, _)| artifact_id.as_str()) + .collect::>(); + if gap_ids != expected_gap_ids { + failures.push(format!( + "{transaction_id} does not disclose every noncomplete server artifact" + )); + } + let optional_only_gap = !gap_ids.is_empty() + && gap_ids.iter().all(|artifact_id| { + parsed.artifacts.get(*artifact_id).is_some_and(|artifact| { + artifact.source_id == "server-sup-wsus" + && matches!( + artifact.state.as_str(), + "skipped" | "unsupported" | "capped" + ) + }) + }); + for artifact_id in &gap_ids { + match parsed.artifacts.get(*artifact_id) { + Some(artifact) + if artifact.state != "captured" || artifact.fragment_complete != Some(true) => { + } + _ => failures.push(format!( + "{transaction_id} coverage gap {artifact_id} is absent or complete" + )), + } + } + match (state, classification) { + ("succeeded", "success") + if terminal_success + && computed_last_success == Some("healthyOrTerminal") + && !terminal_failure + && ((!optional_only_gap + && gap_ids.is_empty() + && confidence == "high" + && confidence_ceiling == "high") + || (optional_only_gap + && confidence == "medium" + && confidence_ceiling == "medium")) => {} + ("failed", "confirmedFailure") + if terminal_failure + && !terminal_success + && gap_ids.is_empty() + && confidence == "high" + && confidence_ceiling == "high" => {} + ("failed", "confirmedFailure") + if terminal_failure + && !terminal_success + && optional_only_gap + && confidence == "medium" + && confidence_ceiling == "medium" => {} + ("deferred", "blockedOrDeferred") + if deferred_seen + && !terminal_failure + && !terminal_success + && confidence == "medium" + && confidence_ceiling == "medium" => {} + ("incomplete", "insufficientEvidence") + if !terminal_failure + && !terminal_success + && confidence == "low" + && confidence_ceiling == "low" => {} + _ => failures.push(format!( + "{transaction_id} state/classification lacks required evidence/coverage" + )), + } + + if state == "incomplete" { + let next_source = transaction["nextSourceId"].as_str(); + if next_source.is_none() + || !parsed.artifacts.values().any(|artifact| { + Some(artifact.source_id.as_str()) == next_source && artifact.state != "captured" + }) + { + failures.push(format!( + "{transaction_id} incomplete state lacks a bounded noncomplete next source" + )); + } + } else if !transaction["nextSourceId"].is_null() { + failures.push(format!("{transaction_id} invents a next source")); + } + } + + let expected_transaction = match scenario { + "incomplete" => Some(("incomplete", "insufficientEvidence", Some("configure"))), + "metadata-failure" => Some(("failed", "confirmedFailure", Some("synchronize"))), + "rotation-boundary" => None, + "sup-setup-failure" => Some(("failed", "confirmedFailure", None)), + "supplemental-wsus-skipped" => Some(("succeeded", "success", Some("healthyOrTerminal"))), + "sync-retry" => Some(("deferred", "blockedOrDeferred", Some("configure"))), + "sync-success" | "unrelated-update-key" => { + Some(("succeeded", "success", Some("healthyOrTerminal"))) + } + "wcm-configuration-failure" => Some(("failed", "confirmedFailure", None)), + "wsus-health-failure" => Some(( + "failed", + "confirmedFailure", + Some("importOrProcessMetadata"), + )), + _ => { + failures.push(format!("unknown scenario outcome contract {scenario}")); + None + } + }; + match (expected_transaction, transactions) { + (None, []) => {} + (Some((state, classification, last_success)), [transaction]) + if transaction["state"] == state + && transaction["classification"] == classification + && (transaction["lastSuccessfulPhase"].as_str() == last_success + || last_success.is_none() && transaction["lastSuccessfulPhase"].is_null()) => {} + _ => failures.push(format!( + "{scenario} does not contain its one exact role-local outcome" + )), + } + + let source_local = match required_array(expected, "sourceLocalObservations", "expected") { + Ok(value) => value, + Err(error) => { + failures.push(error); + &[] + } + }; + let source_local_order = source_local + .iter() + .filter_map(|observation| observation["observationId"].as_str()) + .collect::>(); + let mut sorted_source_local_order = source_local_order.clone(); + sorted_source_local_order.sort_unstable(); + if source_local_order != sorted_source_local_order { + failures.push("source-local observations are not sorted".to_owned()); + } + let actual_source_local_signature = source_local + .iter() + .map(|observation| { + ( + observation["observationId"].as_str(), + observation["classification"].as_str(), + ) + }) + .collect::>(); + let expected_source_local_signature = expected_source_local_signature(scenario) + .iter() + .map(|(observation_id, classification)| (Some(*observation_id), Some(*classification))) + .collect::>(); + if actual_source_local_signature != expected_source_local_signature { + failures.push(format!( + "{scenario} does not retain its exact source-local observation identities" + )); + } + let mut seen_source_local_ids = BTreeSet::new(); + for observation in source_local { + let observation_id = + required_string(observation, "observationId", "sourceLocal").unwrap_or("invalid"); + if !seen_source_local_ids.insert(observation_id) { + failures.push(format!( + "duplicate source-local observationId {observation_id}" + )); + } + reject_unknown_fields( + observation, + &[ + "observationId", + "classification", + "confidence", + "confidenceCeiling", + "correlationEligible", + "artifactIds", + "evidence", + ], + observation_id, + &mut failures, + ); + let classification = observation["classification"].as_str(); + if !matches!( + classification, + Some("ignoredClientEvidence" | "rotationSplit" | "malformedEvidence") + ) || observation["confidence"] != "low" + || observation["confidenceCeiling"] != "low" + || observation["correlationEligible"] != false + { + failures.push(format!("{observation_id} is not safely source-local")); + } + let artifact_id_values = observation["artifactIds"].as_array(); + let artifact_ids = artifact_id_values + .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .unwrap_or_default(); + let mut sorted_artifact_ids = artifact_ids.clone(); + sorted_artifact_ids.sort_unstable(); + sorted_artifact_ids.dedup(); + if artifact_id_values.is_none_or(|values| values.len() != artifact_ids.len()) + || artifact_ids.is_empty() + || artifact_ids != sorted_artifact_ids + { + failures.push(format!( + "{observation_id} artifact IDs are not exact sorted strings" + )); + } + for artifact_id in &artifact_ids { + if !parsed.artifacts.contains_key(*artifact_id) { + failures.push(format!( + "{observation_id} cites unknown artifact ID {artifact_id}" + )); + } + } + let artifacts = artifact_ids + .iter() + .filter_map(|artifact_id| parsed.artifacts.get(*artifact_id)) + .collect::>(); + let references = match required_array(observation, "evidence", observation_id) { + Ok(value) => value, + Err(error) => { + failures.push(error); + &[] + } + }; + let mut cited_ids = BTreeSet::new(); + let mut seen_source_local_evidence = BTreeSet::new(); + for reference in references { + reject_unknown_fields( + reference, + &["artifactId", "startLine", "endLine"], + &format!("{observation_id}.evidence"), + &mut failures, + ); + if let Ok(artifact_id) = required_string(reference, "artifactId", observation_id) { + cited_ids.insert(artifact_id); + let identity = ( + artifact_id, + reference["startLine"].as_u64(), + reference["endLine"].as_u64(), + ); + if !seen_source_local_evidence.insert(identity) { + failures.push(format!( + "{observation_id} cites one physical logical record more than once" + )); + } + if !artifact_ids.contains(&artifact_id) { + failures.push(format!("{observation_id} evidence escapes artifactIds")); + } + } + if let Err(error) = evidence_for(parsed, reference, observation_id) { + failures.push(error); + } + } + let semantics_match = match classification { + Some("ignoredClientEvidence") => { + !references.is_empty() + && cited_ids == artifact_ids.iter().copied().collect::>() + && artifacts.iter().all(|artifact| { + artifact.role == "client" + && artifact.source_id == "client-updates-control" + && matches!(artifact.state.as_str(), "captured" | "capped") + }) + } + Some("rotationSplit") => { + let sources = artifacts + .iter() + .map(|artifact| artifact.source_id.as_str()) + .collect::>(); + let roles = artifacts + .iter() + .map(|artifact| artifact.role.as_str()) + .collect::>(); + let producer_hosts = artifacts + .iter() + .map(|artifact| artifact.producer_host.as_str()) + .collect::>(); + let workflow_subject_roles = artifacts + .iter() + .map(|artifact| artifact.workflow_subject_role.as_str()) + .collect::>(); + let workflow_subject_handles = artifacts + .iter() + .map(|artifact| artifact.workflow_subject_handle.as_str()) + .collect::>(); + let source_versions = artifacts + .iter() + .map(|artifact| artifact.source_version.as_str()) + .collect::>(); + let basenames = artifacts + .iter() + .map(|artifact| artifact.basename.to_ascii_lowercase()) + .collect::>(); + let lineages = artifacts + .iter() + .map(|artifact| artifact.rotation_lineage.as_str()) + .collect::>(); + let rotations = artifacts + .iter() + .map(|artifact| artifact.rotation_kind.as_str()) + .collect::>(); + references.is_empty() + && artifacts.len() >= 2 + && sources.len() == 1 + && roles.len() == 1 + && producer_hosts.len() == 1 + && workflow_subject_roles.len() == 1 + && workflow_subject_handles.len() == 1 + && source_versions.len() == 1 + && basenames.len() == 1 + && lineages.len() == 1 + && lineages.first().is_some_and(|lineage| !lineage.is_empty()) + && rotations.len() >= 2 + && artifacts.iter().all(|artifact| { + artifact.role != "client" + && matches!(artifact.state.as_str(), "captured" | "capped") + && artifact.fragment_complete == Some(false) + }) + } + Some("malformedEvidence") => { + references.is_empty() + && !artifacts.is_empty() + && artifacts.iter().all(|artifact| { + artifact.role != "client" && artifact.state == "parseFailed" + }) + } + _ => false, + }; + if !semantics_match { + failures.push(format!( + "{observation_id} classification is detached from physical semantics" + )); + } + } + let requests = match required_array(expected, "artifactRequests", "expected") { + Ok(value) => value, + Err(error) => { + failures.push(error); + &[] + } + }; + let mut request_order = Vec::new(); + for request in requests { + reject_unknown_fields( + request, + &["sourceId", "reasonCode"], + "artifactRequest", + &mut failures, + ); + let source_id = + required_string(request, "sourceId", "artifactRequest").unwrap_or("invalid"); + let reason_code = + required_string(request, "reasonCode", "artifactRequest").unwrap_or("invalid"); + request_order.push((source_id, reason_code)); + let matching_coverage = parsed.artifacts.values().any(|artifact| { + artifact.source_id == source_id + && match reason_code { + "coverageAbsent" => artifact.state == "absent", + "coverageAccessDenied" => artifact.state == "accessDenied", + "coverageCapped" => artifact.state == "capped", + "coverageMalformed" => artifact.state == "parseFailed", + "coverageRotationSplit" => { + matches!(artifact.state.as_str(), "captured" | "capped") + && artifact.fragment_complete == Some(false) + } + _ => false, + } + }); + if !matches!(source_id, "server-sup-sync" | "server-sup-wsus") + || !matches!( + reason_code, + "coverageAbsent" + | "coverageAccessDenied" + | "coverageCapped" + | "coverageMalformed" + | "coverageRotationSplit" + ) + || !matching_coverage + { + failures.push(format!( + "artifact request {source_id}/{reason_code} is not bounded by coverage" + )); + } + } + let mut sorted_requests = request_order.clone(); + sorted_requests.sort_unstable(); + sorted_requests.dedup(); + if request_order != sorted_requests { + failures.push("artifact requests are not sorted/unique".to_owned()); + } + let expected_requests: &[(&str, &str)] = match scenario { + "incomplete" => &[ + ("server-sup-sync", "coverageAbsent"), + ("server-sup-sync", "coverageAccessDenied"), + ], + "rotation-boundary" => &[ + ("server-sup-sync", "coverageMalformed"), + ("server-sup-sync", "coverageRotationSplit"), + ], + _ => &[], + }; + if request_order != expected_requests { + failures.push(format!( + "{scenario} does not retain its exact bounded coverage requests" + )); + } + + if expected["clientCausalClaims"] != json!([]) + || expected["correlationHandoff"]["issue"] != "#333" + || expected["correlationHandoff"]["performed"] != false + || expected["correlationHandoff"]["timeOnlyEligible"] != false + { + failures.push("expected output enables a premature client/SUP causal claim".to_owned()); + } + let sup_observed = manifest["topology"]["rolesObserved"] + .as_array() + .is_some_and(|roles| roles.iter().any(|role| role == "softwareUpdatePoint")); + if expected["roleAssessment"]["softwareUpdatePointObserved"].as_bool() != Some(sup_observed) { + failures.push("role assessment is not an exact topology projection".to_owned()); + } + if scenario == "rotation-boundary" && !transactions.is_empty() { + failures.push("rotation fragments formed a SUP transaction".to_owned()); + } + if scenario == "unrelated-update-key" + && (transactions.len() != 1 + || !parsed + .artifacts + .values() + .any(|artifact| artifact.role == "client")) + { + failures + .push("unrelated client update did not stay outside one server transaction".to_owned()); + } + if scenario == "supplemental-wsus-skipped" + && transactions.first().is_none_or(|transaction| { + transaction["confidence"] != "medium" || transaction["classification"] != "success" + }) + { + failures.push("skipped optional WSUS evidence did not lower confidence only".to_owned()); + } + + if failures.is_empty() { + Ok(()) + } else { + Err(failures) + } +} + +fn validate_scenario_values( + scenario: &str, + manifest: &Value, + expected: &Value, +) -> Result<(), Vec> { + validate_scenario_values_with_overrides(scenario, manifest, expected, &BTreeMap::new()) +} + +fn validate_scenario_values_with_overrides( + scenario: &str, + manifest: &Value, + expected: &Value, + fixture_overrides: &BTreeMap>, +) -> Result<(), Vec> { + let scenario_root = corpus_root().join(scenario); + let parsed = validate_manifest(scenario, &scenario_root, manifest, fixture_overrides)?; + validate_expected(scenario, manifest, expected, &parsed) +} + +fn mutation_was_accepted(scenario: &str, manifest: &Value, expected: &Value) -> bool { + validate_scenario_values(scenario, manifest, expected).is_ok() +} + +fn mutation_was_accepted_with_asset( + scenario: &str, + manifest: &Value, + expected: &Value, + evidence_path: &str, + mutation_asset: &str, +) -> bool { + let asset_path = mutation_asset_root().join(mutation_asset); + let bytes = std::fs::read(&asset_path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", asset_path.display())); + let overrides = BTreeMap::from([(evidence_path.to_owned(), bytes)]); + validate_scenario_values_with_overrides(scenario, manifest, expected, &overrides).is_ok() +} + +fn mutation_asset_schema_failures(manifest: &Value) -> Vec { + let mut failures = Vec::new(); + reject_unknown_fields( + manifest, + &["contractVersion", "syntheticFixture", "testOnly", "assets"], + "mutation-assets", + &mut failures, + ); + if manifest["contractVersion"] != 1 + || manifest["syntheticFixture"] != true + || manifest["testOnly"] != true + { + failures.push( + "mutation assets must retain the versioned synthetic test-only boundary".to_owned(), + ); + } + + let Some(assets) = manifest["assets"].as_array() else { + failures.push("mutation-assets.assets must be an array".to_owned()); + return failures; + }; + for (index, asset) in assets.iter().enumerate() { + let context = format!("mutation-assets.assets[{index}]"); + reject_unknown_fields( + asset, + &["assetId", "relativePath", "bytesCopied", "testPurpose"], + &context, + &mut failures, + ); + for field in ["assetId", "relativePath", "testPurpose"] { + if required_string(asset, field, &context).is_err() { + failures.push(format!("{context}.{field} must be a string")); + } + } + if asset["bytesCopied"].as_u64().is_none() { + failures.push(format!("{context}.bytesCopied must be an unsigned integer")); + } + } + + failures +} + +#[test] +fn software_update_point_scenario_matrix_is_complete_and_loadable() { + let root = corpus_root(); + let mut actual = std::fs::read_dir(&root) + .unwrap_or_else(|error| panic!("{} is readable: {error}", root.display())) + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then(|| { + path.file_name() + .expect("scenario directory has a name") + .to_string_lossy() + .into_owned() + }) + }) + .collect::>(); + actual.sort(); + assert_eq!(actual, SCENARIOS, "the #330 scenario matrix changed"); + + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let expected = read_json(scenario, "expected.json") + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + validate_scenario_values(scenario, &manifest, &expected) + .unwrap_or_else(|failures| panic!("{scenario}:\n{}", failures.join("\n"))); + } +} + +#[test] +fn every_scenario_evidence_asset_is_manifest_and_coverage_closed() { + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json").expect("manifest loads"); + let expected = read_json(scenario, "expected.json").expect("expected loads"); + let scenario_root = corpus_root().join(scenario); + let evidence_files = relative_fixture_files(&scenario_root.join("evidence")) + .unwrap_or_else(|error| panic!("{scenario}: {error}")) + .into_iter() + .map(|path| format!("evidence/{path}")) + .collect::>(); + let physical_artifacts = manifest["artifacts"] + .as_array() + .expect("manifest.artifacts is an array") + .iter() + .filter(|artifact| { + matches!( + artifact["captureState"].as_str(), + Some("captured" | "capped" | "parseFailed") + ) + }) + .map(|artifact| { + ( + artifact["relativePath"] + .as_str() + .expect("physical artifact has relativePath") + .to_owned(), + artifact["artifactId"] + .as_str() + .expect("physical artifact has artifactId") + .to_owned(), + ) + }) + .collect::>(); + let coverage_ids = expected["coverage"] + .as_array() + .expect("expected.coverage is an array") + .iter() + .map(|coverage| { + coverage["artifactId"] + .as_str() + .expect("coverage has artifactId") + .to_owned() + }) + .collect::>(); + + assert_eq!( + evidence_files, + physical_artifacts.keys().cloned().collect(), + "{scenario} has physical fixture assets outside its manifest" + ); + assert!( + physical_artifacts + .values() + .all(|artifact_id| coverage_ids.contains(artifact_id)), + "{scenario} has a physical manifest artifact outside expected coverage" + ); + } +} + +#[test] +fn mutation_asset_contract_rejects_unknown_and_capture_masquerade_fields() { + let manifest_path = mutation_asset_root().join("manifest.json"); + let manifest: Value = serde_json::from_str( + &std::fs::read_to_string(&manifest_path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", manifest_path.display())), + ) + .unwrap_or_else(|error| panic!("{} contains valid JSON: {error}", manifest_path.display())); + let mut accepted = Vec::new(); + + let mut unknown_top_level = manifest.clone(); + unknown_top_level["sccmManifestVersion"] = json!(1); + if mutation_asset_schema_failures(&unknown_top_level).is_empty() { + accepted.push("unknown top-level manifest vocabulary"); + } + + let mut unknown_asset_row = manifest.clone(); + unknown_asset_row["assets"][0]["description"] = json!("not part of the contract"); + if mutation_asset_schema_failures(&unknown_asset_row).is_empty() { + accepted.push("unknown mutation asset-row vocabulary"); + } + + let mut captured_artifact_masquerade = manifest.clone(); + captured_artifact_masquerade["assets"][0]["artifactId"] = json!("captured-artifact-01"); + captured_artifact_masquerade["assets"][0]["sourceId"] = json!("server-sup-sync"); + captured_artifact_masquerade["assets"][0]["captureState"] = json!("captured"); + if mutation_asset_schema_failures(&captured_artifact_masquerade).is_empty() { + accepted.push("captured-artifact masquerade vocabulary"); + } + + let mut captured_manifest_masquerade = manifest; + captured_manifest_masquerade["assets"][0]["proposalOnly"] = json!(true); + captured_manifest_masquerade["assets"][0]["syntheticFixture"] = json!(true); + captured_manifest_masquerade["assets"][0]["sccmManifestVersion"] = json!(1); + if mutation_asset_schema_failures(&captured_manifest_masquerade).is_empty() { + accepted.push("captured-manifest masquerade vocabulary"); + } + + assert!( + accepted.is_empty(), + "the mutation-only asset contract accepted schema bypasses: {accepted:?}" + ); +} + +#[test] +fn mutation_assets_have_an_explicit_separate_test_contract() { + let root = mutation_asset_root(); + let manifest_path = root.join("manifest.json"); + let manifest: Value = serde_json::from_str( + &std::fs::read_to_string(&manifest_path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", manifest_path.display())), + ) + .unwrap_or_else(|error| panic!("{} contains valid JSON: {error}", manifest_path.display())); + let schema_failures = mutation_asset_schema_failures(&manifest); + assert!( + schema_failures.is_empty(), + "mutation asset schema failed closed:\n{}", + schema_failures.join("\n") + ); + assert_eq!(manifest["contractVersion"], 1); + assert_eq!(manifest["syntheticFixture"], true); + assert_eq!(manifest["testOnly"], true); + + let assets = manifest["assets"] + .as_array() + .expect("mutation assets are an array"); + let actual_asset_files = relative_fixture_files(&root) + .expect("mutation asset directory is readable") + .into_iter() + .filter(|path| path != "manifest.json") + .collect::>(); + let declared_asset_files = assets + .iter() + .map(|asset| { + asset["relativePath"] + .as_str() + .expect("mutation asset has relativePath") + .to_owned() + }) + .collect::>(); + assert_eq!( + actual_asset_files, declared_asset_files, + "mutation-only bytes must not exist outside their explicit test contract" + ); + + let actual_contract = assets + .iter() + .map(|asset| { + let relative_path = asset["relativePath"] + .as_str() + .expect("mutation asset has relativePath"); + let bytes = std::fs::read(root.join(relative_path)) + .unwrap_or_else(|error| panic!("{relative_path} is readable: {error}")); + assert_eq!( + asset["bytesCopied"].as_u64(), + Some(bytes.len() as u64), + "{relative_path} retains an exact byte count" + ); + assert!( + String::from_utf8_lossy(&bytes).contains("SYNTHETIC FIXTURE"), + "{relative_path} retains its synthetic marker" + ); + ( + asset["assetId"] + .as_str() + .expect("mutation asset has assetId"), + relative_path, + asset["testPurpose"] + .as_str() + .expect("mutation asset has testPurpose"), + ) + }) + .collect::>(); + assert_eq!( + actual_contract, + [ + ( + "cross-family-lo-wcm", + "cross-family-lo-wcm.log", + "rejectCrossFamilyRotationGrouping" + ), + ( + "incomplete-required-numbered-wsyncmgr", + "incomplete-required-numbered-wsyncmgr.log", + "rejectIncompleteRequiredRotationSuccess" + ), + ( + "parse-failed-valid-numbered-wsusctrl", + "parse-failed-valid-numbered-wsusctrl.log", + "rejectParseFailedUsableCcm" + ), + ], + "the bounded mutation-asset contract changed" + ); +} + +#[test] +fn structured_fields_are_unique_closed_and_not_nested_ccm() { + let valid = "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=synchronize; Disposition=succeeded; Terminal=false; SyncRunId=sync-01; SiteCode=LAB; SupHandle=safe:sup:lab-sup-01; ProfileId=sup-server-5.00.test-v1"; + assert!(parse_fixture_fields(valid).is_ok()); + for invalid in [ + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=synchronize; Phase=validateWsus; Disposition=succeeded; Terminal=false", + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=synchronize; Disposition=succeeded; Terminal=false; Terminal=true", + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=synchronize; Disposition=succeeded; Terminal=false; ServerCause=network", + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=synchronize]LOG]!>; Disposition=succeeded; Terminal=false", + ] { + assert!( + parse_fixture_fields(invalid).is_err(), + "ambiguous or unsupported fields were accepted: {invalid}" + ); + } +} + +#[test] +fn rotation_metadata_must_bind_to_source_and_evidence_paths() { + let success_manifest = read_json("sync-success", "manifest.json").expect("manifest loads"); + let success_expected = read_json("sync-success", "expected.json").expect("expected loads"); + let rotation_manifest = + read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let rotation_expected = + read_json("rotation-boundary", "expected.json").expect("expected loads"); + let wcm = artifact_index(&success_manifest, "sync-success-01-wcm"); + let lo = artifact_index(&rotation_manifest, "rotation-02-lo"); + let current = artifact_index(&rotation_manifest, "rotation-01-current"); + let mut accepted = Vec::new(); + + let mut unbound_kind = success_manifest.clone(); + unbound_kind["artifacts"][wcm]["rotation"]["kind"] = json!("lo_"); + if mutation_was_accepted("sync-success", &unbound_kind, &success_expected) { + accepted.push("lo_ rotation retained current source and destination paths"); + } + + let mut unbound_source = success_manifest.clone(); + unbound_source["artifacts"][wcm]["rotation"]["kind"] = json!("lo_"); + unbound_source["artifacts"][wcm]["relativePath"] = + json!("evidence/server-sup-sync/site/lo_/WCM.log"); + let current_wcm_path = corpus_root() + .join("sync-success") + .join("evidence/server-sup-sync/site/current/WCM.log"); + let current_wcm_bytes = std::fs::read(¤t_wcm_path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", current_wcm_path.display())); + let unbound_source_overrides = BTreeMap::from([( + "evidence/server-sup-sync/site/lo_/WCM.log".to_owned(), + current_wcm_bytes, + )]); + if validate_scenario_values_with_overrides( + "sync-success", + &unbound_source, + &success_expected, + &unbound_source_overrides, + ) + .is_ok() + { + accepted.push("lo_ rotation retained a current sanitized source path"); + } + + let mut unbound_number = success_manifest.clone(); + unbound_number["artifacts"][wcm]["rotation"]["kind"] = json!("numbered"); + unbound_number["artifacts"][wcm]["rotation"]["value"] = json!(1); + unbound_number["artifacts"][wcm]["sanitizedSourcePath"] = + json!("SYNTHETIC://configured-root/Site/Logs/WCM.log.1"); + if mutation_was_accepted("sync-success", &unbound_number, &success_expected) { + accepted.push("numbered rotation value was absent from its evidence destination"); + } + + let mut duplicate_physical_source = rotation_manifest.clone(); + duplicate_physical_source["artifacts"][lo]["sanitizedSourcePath"] = + duplicate_physical_source["artifacts"][current]["sanitizedSourcePath"].clone(); + if mutation_was_accepted( + "rotation-boundary", + &duplicate_physical_source, + &rotation_expected, + ) { + accepted.push("self-declared rotation metadata disguised one physical source collision"); + } + + let mut unsafe_timestamp = success_manifest.clone(); + unsafe_timestamp["artifacts"][wcm]["rotation"]["kind"] = json!("timestamped"); + unsafe_timestamp["artifacts"][wcm]["rotation"]["value"] = json!("20260730-150060"); + unsafe_timestamp["artifacts"][wcm]["sanitizedSourcePath"] = + json!("SYNTHETIC://configured-root/Site/Logs/WCM.log.20260730-150060"); + unsafe_timestamp["artifacts"][wcm]["relativePath"] = + json!("evidence/server-sup-sync/site/timestamped-20260730-150060/WCM.log"); + if mutation_was_accepted("sync-success", &unsafe_timestamp, &success_expected) { + accepted.push("noncanonical rotation timestamp"); + } + + let mut path_like_timestamp = success_manifest.clone(); + path_like_timestamp["artifacts"][wcm]["rotation"]["kind"] = json!("timestamped"); + path_like_timestamp["artifacts"][wcm]["rotation"]["value"] = + json!("../../Users/Real/secret.log"); + if mutation_was_accepted("sync-success", &path_like_timestamp, &success_expected) { + accepted.push("path-like rotation timestamp"); + } + + let mut unsafe_lineage = success_manifest.clone(); + unsafe_lineage["artifacts"][wcm]["rotation"]["lineageId"] = json!("C:\\Users\\Real\\WCM.log"); + if mutation_was_accepted("sync-success", &unsafe_lineage, &success_expected) { + accepted.push("unsafe rotation lineage syntax"); + } + + assert!( + accepted.is_empty(), + "unbound or unsafe rotation provenance was accepted: {accepted:?}" + ); +} + +#[test] +fn canonical_numbered_and_timestamped_rotation_bindings_remain_loadable() { + let manifest = read_json("sync-success", "manifest.json").expect("manifest loads"); + let expected = read_json("sync-success", "expected.json").expect("expected loads"); + let wcm = artifact_index(&manifest, "sync-success-01-wcm"); + let current_path = corpus_root() + .join("sync-success") + .join("evidence/server-sup-sync/site/current/WCM.log"); + let bytes = std::fs::read(¤t_path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", current_path.display())); + + for (kind, value, source_path, evidence_path) in [ + ( + "numbered", + json!(1), + "SYNTHETIC://configured-root/Site/Logs/WCM.log.1", + "evidence/server-sup-sync/site/numbered-1/WCM.log", + ), + ( + "timestamped", + json!("20260730-150000"), + "SYNTHETIC://configured-root/Site/Logs/WCM.log.20260730-150000", + "evidence/server-sup-sync/site/timestamped-20260730-150000/WCM.log", + ), + ] { + let mut rotated = manifest.clone(); + rotated["artifacts"][wcm]["rotation"]["kind"] = json!(kind); + rotated["artifacts"][wcm]["rotation"]["value"] = value; + rotated["artifacts"][wcm]["sanitizedSourcePath"] = json!(source_path); + rotated["artifacts"][wcm]["relativePath"] = json!(evidence_path); + let overrides = BTreeMap::from([(evidence_path.to_owned(), bytes.clone())]); + validate_scenario_values_with_overrides("sync-success", &rotated, &expected, &overrides) + .unwrap_or_else(|failures| { + panic!("{kind} canonical binding:\n{}", failures.join("\n")) + }); + } +} + +#[test] +fn exact_keys_terminal_evidence_and_client_causality_fail_closed() { + let manifest = read_json("sync-success", "manifest.json").expect("manifest loads"); + let expected = read_json("sync-success", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + let transaction_id = "sup:sync-01:LAB:safe:sup:lab-sup-01"; + let transaction = transaction_index(&expected, transaction_id); + + let mut key_alias = expected.clone(); + key_alias["transactions"][transaction]["key"]["syncRunId"] = json!("sync-other"); + if mutation_was_accepted("sync-success", &manifest, &key_alias) { + accepted.push("transaction key diverged from every cited record"); + } + let mut terminal_removed = expected.clone(); + let terminal_observation = observation_index(&expected, transaction_id, "sync-01-06-terminal"); + terminal_removed["transactions"][transaction]["observations"][terminal_observation] + ["terminal"] = json!(false); + if mutation_was_accepted("sync-success", &manifest, &terminal_removed) { + accepted.push("success survived without cited terminal evidence"); + } + let mut time_only = expected.clone(); + time_only["clientCausalClaims"] = + json!(["A same-time client scan proves the SUP caused the failure."]); + if mutation_was_accepted("sync-success", &manifest, &time_only) { + accepted.push("time-only client/SUP causality was admitted"); + } + + assert!( + accepted.is_empty(), + "key/terminal/causality mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn coverage_role_nonphysical_and_capture_time_fail_closed() { + let manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); + let expected = read_json("incomplete", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + let wcm = artifact_index(&manifest, "incomplete-01-wcm"); + let denied = artifact_index(&manifest, "incomplete-02-wsync-denied"); + + let mut role_inferred = expected.clone(); + role_inferred["roleAssessment"]["softwareUpdatePointObserved"] = json!(false); + role_inferred["roleAssessment"]["roleAbsentInferred"] = json!(true); + if mutation_was_accepted("incomplete", &manifest, &role_inferred) { + accepted.push("missing sources erased an observed SUP role"); + } + let mut host_alias = manifest.clone(); + host_alias["artifacts"][wcm]["producerHostHandle"] = json!(EXACT_SUP); + if mutation_was_accepted("incomplete", &host_alias, &expected) { + accepted.push("site-server producer collapsed onto the SUP subject"); + } + let mut physical_invention = manifest.clone(); + physical_invention["artifacts"][denied]["collectionLimit"] = + json!({"byteLimit": 4096, "limitApplied": false}); + if mutation_was_accepted("incomplete", &physical_invention, &expected) { + accepted.push("access-denied artifact invented physical collection provenance"); + } + let mut early_capture = manifest.clone(); + early_capture["bundle"]["capturedUtc"] = json!("2026-07-30T00:00:00Z"); + if mutation_was_accepted("incomplete", &early_capture, &expected) { + accepted.push("evidence after the bundle capture was accepted"); + } + + assert!( + accepted.is_empty(), + "coverage/role/provenance mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn source_local_semantics_ordering_and_transaction_uniqueness_fail_closed() { + let rotation_manifest = + read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let rotation_expected = + read_json("rotation-boundary", "expected.json").expect("expected loads"); + let unrelated_manifest = + read_json("unrelated-update-key", "manifest.json").expect("manifest loads"); + let unrelated_expected = + read_json("unrelated-update-key", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + let rotation_split = source_local_index(&rotation_expected, "rotation-01-split"); + let malformed = source_local_index(&rotation_expected, "rotation-02-malformed"); + let ignored_client = source_local_index(&unrelated_expected, "unrelated-client-01"); + let unrelated_transaction_id = "sup:sync-08:LAB:safe:sup:lab-sup-01:update-server-a"; + let unrelated_transaction = transaction_index(&unrelated_expected, unrelated_transaction_id); + + let mut split_as_client = rotation_expected.clone(); + split_as_client["sourceLocalObservations"][rotation_split]["classification"] = + json!("ignoredClientEvidence"); + if mutation_was_accepted("rotation-boundary", &rotation_manifest, &split_as_client) { + accepted.push("server rotation split was relabeled as client evidence"); + } + let mut malformed_as_split = rotation_expected.clone(); + malformed_as_split["sourceLocalObservations"][malformed]["classification"] = + json!("rotationSplit"); + if mutation_was_accepted("rotation-boundary", &rotation_manifest, &malformed_as_split) { + accepted.push("parse-failed evidence was relabeled as rotation split"); + } + let mut client_as_malformed = unrelated_expected.clone(); + client_as_malformed["sourceLocalObservations"][ignored_client]["classification"] = + json!("malformedEvidence"); + if mutation_was_accepted( + "unrelated-update-key", + &unrelated_manifest, + &client_as_malformed, + ) { + accepted.push("client evidence was relabeled as malformed server evidence"); + } + let mut reversed = unrelated_expected.clone(); + reversed["transactions"][unrelated_transaction]["observations"] + .as_array_mut() + .expect("observations are mutable") + .reverse(); + if mutation_was_accepted("unrelated-update-key", &unrelated_manifest, &reversed) { + accepted.push("reversed observations were accepted"); + } + let mut duplicated = unrelated_expected.clone(); + let duplicate = duplicated["transactions"][unrelated_transaction].clone(); + duplicated["transactions"] + .as_array_mut() + .expect("transactions are mutable") + .push(duplicate); + if mutation_was_accepted("unrelated-update-key", &unrelated_manifest, &duplicated) { + accepted.push("duplicate exact transaction was accepted"); + } + + assert!( + accepted.is_empty(), + "source-local/order/identity mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn physical_collisions_unknown_fields_and_update_key_borrowing_fail_closed() { + let manifest = read_json("unrelated-update-key", "manifest.json").expect("manifest loads"); + let expected = read_json("unrelated-update-key", "expected.json").expect("expected loads"); + let rotation_manifest = + read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let rotation_expected = + read_json("rotation-boundary", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let current = artifact_index(&rotation_manifest, "rotation-01-current"); + let lo = artifact_index(&rotation_manifest, "rotation-02-lo"); + let mut collision = rotation_manifest.clone(); + collision["artifacts"][lo]["relativePath"] = + collision["artifacts"][current]["relativePath"].clone(); + collision["artifacts"][lo]["bytesCopied"] = + collision["artifacts"][current]["bytesCopied"].clone(); + if mutation_was_accepted("rotation-boundary", &collision, &rotation_expected) { + accepted.push("physical evidence destination collision"); + } + let mut unknown_cause = expected.clone(); + unknown_cause["serverCause"] = json!("SUP caused the client scan failure"); + if mutation_was_accepted("unrelated-update-key", &manifest, &unknown_cause) { + accepted.push("unknown causal field"); + } + let mut borrowed_update = expected.clone(); + let transaction = transaction_index( + &expected, + "sup:sync-08:LAB:safe:sup:lab-sup-01:update-server-a", + ); + borrowed_update["transactions"][transaction]["key"]["updateId"] = json!("update-client-b"); + if mutation_was_accepted("unrelated-update-key", &manifest, &borrowed_update) { + accepted.push("client update identity was borrowed into the server transaction"); + } + + assert!( + accepted.is_empty(), + "collision/schema/update-key mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn scenario_cardinality_rotation_shape_and_provenance_fail_closed() { + let incomplete_manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); + let incomplete_expected = read_json("incomplete", "expected.json").expect("expected loads"); + let success_manifest = read_json("sync-success", "manifest.json").expect("manifest loads"); + let success_expected = read_json("sync-success", "expected.json").expect("expected loads"); + let supplemental_manifest = + read_json("supplemental-wsus-skipped", "manifest.json").expect("manifest loads"); + let supplemental_expected = + read_json("supplemental-wsus-skipped", "expected.json").expect("expected loads"); + let rotation_manifest = + read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let rotation_expected = + read_json("rotation-boundary", "expected.json").expect("expected loads"); + let unrelated_manifest = + read_json("unrelated-update-key", "manifest.json").expect("manifest loads"); + let unrelated_expected = + read_json("unrelated-update-key", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + let success_transaction_id = "sup:sync-01:LAB:safe:sup:lab-sup-01"; + let success_transaction = transaction_index(&success_expected, success_transaction_id); + + let mut missing_transaction = success_expected.clone(); + missing_transaction["transactions"] = json!([]); + if mutation_was_accepted("sync-success", &success_manifest, &missing_transaction) { + accepted.push("required scenario transaction was deleted"); + } + let mut duplicate_evidence = success_expected.clone(); + let configure_observation = observation_index( + &success_expected, + success_transaction_id, + "sync-01-01-configure", + ); + let duplicate_reference = duplicate_evidence["transactions"][success_transaction] + ["observations"][configure_observation]["evidence"][0] + .clone(); + duplicate_evidence["transactions"][success_transaction]["observations"][configure_observation] + ["evidence"] + .as_array_mut() + .expect("evidence is mutable") + .push(duplicate_reference); + if mutation_was_accepted("sync-success", &success_manifest, &duplicate_evidence) { + accepted.push("one physical logical record was cited twice"); + } + let mut omitted_gap = supplemental_expected.clone(); + let supplemental_transaction = transaction_index( + &supplemental_expected, + "sup:sync-07:LAB:safe:sup:lab-sup-01", + ); + omitted_gap["transactions"][supplemental_transaction]["coverageGapArtifactIds"] = json!([]); + omitted_gap["transactions"][supplemental_transaction]["confidence"] = json!("high"); + omitted_gap["transactions"][supplemental_transaction]["confidenceCeiling"] = json!("high"); + if mutation_was_accepted( + "supplemental-wsus-skipped", + &supplemental_manifest, + &omitted_gap, + ) { + accepted.push("optional skipped coverage disappeared from the transaction"); + } + let mut unexpected_role = success_manifest.clone(); + unexpected_role["topology"]["rolesObserved"] = + json!(["siteServer", "softwareUpdatePoint", "unknownRole", "wsUs"]); + if mutation_was_accepted("sync-success", &unexpected_role, &success_expected) { + accepted.push("uncatalogued topology role was accepted"); + } + let mut duplicate_fingerprint = success_manifest.clone(); + let wcm = artifact_index(&success_manifest, "sync-success-01-wcm"); + let wsync = artifact_index(&success_manifest, "sync-success-02-wsync"); + duplicate_fingerprint["artifacts"][wsync]["pathFingerprint"] = + duplicate_fingerprint["artifacts"][wcm]["pathFingerprint"].clone(); + if mutation_was_accepted("sync-success", &duplicate_fingerprint, &success_expected) { + accepted.push("two physical artifacts shared one path fingerprint"); + } + let mut shaped_current = success_manifest.clone(); + shaped_current["artifacts"][wcm]["rotation"]["value"] = json!("lo_"); + if mutation_was_accepted("sync-success", &shaped_current, &success_expected) { + accepted.push("current rotation accepted an incompatible value"); + } + let mut missing_fragment_state = success_manifest.clone(); + missing_fragment_state["artifacts"][wcm]["rotation"] + .as_object_mut() + .expect("rotation is mutable") + .remove("fragmentComplete"); + if mutation_was_accepted("sync-success", &missing_fragment_state, &success_expected) { + accepted.push("physical artifact omitted fragment completeness"); + } + let mut late_parse_failure = rotation_manifest.clone(); + let malformed = artifact_index(&rotation_manifest, "rotation-03-malformed"); + late_parse_failure["artifacts"][malformed]["collectedUtc"] = json!("2026-07-30T20:00:00Z"); + if mutation_was_accepted("rotation-boundary", &late_parse_failure, &rotation_expected) { + accepted.push("parse-failed artifact was collected after its bundle"); + } + let mut missing_rotation_observations = rotation_expected.clone(); + missing_rotation_observations["sourceLocalObservations"] = json!([]); + if mutation_was_accepted( + "rotation-boundary", + &rotation_manifest, + &missing_rotation_observations, + ) { + accepted.push("rotation and malformed source-local observations were deleted"); + } + let mut missing_client_observation = unrelated_expected.clone(); + missing_client_observation["sourceLocalObservations"] = json!([]); + if mutation_was_accepted( + "unrelated-update-key", + &unrelated_manifest, + &missing_client_observation, + ) { + accepted.push("ignored client observation was deleted"); + } + let mut missing_requests = incomplete_expected.clone(); + missing_requests["artifactRequests"] = json!([]); + if mutation_was_accepted("incomplete", &incomplete_manifest, &missing_requests) { + accepted.push("bounded incomplete-coverage requests were deleted"); + } + + assert!( + accepted.is_empty(), + "scenario/rotation/provenance mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn terminal_failure_with_optional_gap_has_a_medium_confidence_ceiling() { + let mut manifest = + read_json("wcm-configuration-failure", "manifest.json").expect("manifest loads"); + manifest["artifacts"] + .as_array_mut() + .expect("artifacts are mutable") + .push(json!({ + "artifactId": "wcm-failure-02-wsus-health", + "sourceId": "server-sup-wsus", + "producerRole": "wsUs", + "producerHostHandle": EXACT_WSUS, + "workflowSubjectRole": "softwareUpdatePoint", + "workflowSubjectHandle": EXACT_SUP, + "sourceKind": "profileDefined", + "originalBasename": "WsusHealth.json", + "sanitizedSourcePath": "SYNTHETIC://configured-root/WSUS/WsusHealth.json", + "pathFingerprint": "synthetic:wcm-failure-wsus-health", + "rotation": { + "kind": "current", + "lineageId": "wcm-failure-wsus-health" + }, + "captureState": "skipped", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T18:00:00Z" + })); + + let mut expected = + read_json("wcm-configuration-failure", "expected.json").expect("expected loads"); + expected["coverage"] + .as_array_mut() + .expect("coverage is mutable") + .push(json!({ + "artifactId": "wcm-failure-02-wsus-health", + "state": "skipped" + })); + let transaction = transaction_index(&expected, "sup:sync-02:LAB:safe:sup:lab-sup-01"); + expected["transactions"][transaction]["coverageGapArtifactIds"] = + json!(["wcm-failure-02-wsus-health"]); + + assert!( + validate_scenario_values("wcm-configuration-failure", &manifest, &expected).is_err(), + "high-confidence terminal failure survived an explicit optional coverage gap" + ); + + expected["transactions"][transaction]["confidence"] = json!("medium"); + expected["transactions"][transaction]["confidenceCeiling"] = json!("medium"); + validate_scenario_values("wcm-configuration-failure", &manifest, &expected) + .unwrap_or_else(|failures| panic!("{}", failures.join("\n"))); +} + +#[test] +fn required_phase_identity_and_manifest_strings_fail_closed() { + let success_manifest = read_json("sync-success", "manifest.json").expect("manifest loads"); + let success_expected = read_json("sync-success", "expected.json").expect("expected loads"); + let rotation_manifest = + read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let rotation_expected = + read_json("rotation-boundary", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + let transaction_id = "sup:sync-01:LAB:safe:sup:lab-sup-01"; + let transaction = transaction_index(&success_expected, transaction_id); + + let mut missing_required_phase = success_expected.clone(); + let synchronize = + observation_index(&success_expected, transaction_id, "sync-01-02-synchronize"); + missing_required_phase["transactions"][transaction]["observations"] + .as_array_mut() + .expect("observations are mutable") + .remove(synchronize); + if mutation_was_accepted("sync-success", &success_manifest, &missing_required_phase) { + accepted.push("sync-success survived without its required synchronize phase"); + } + + let mut renamed_observation = success_expected.clone(); + let terminal = observation_index(&success_expected, transaction_id, "sync-01-06-terminal"); + renamed_observation["transactions"][transaction]["observations"][terminal]["observationId"] = + json!("sync-01-06-terminal-renamed"); + if mutation_was_accepted("sync-success", &success_manifest, &renamed_observation) { + accepted.push("a scenario observation identity was renamed"); + } + + let current = artifact_index(&rotation_manifest, "rotation-01-current"); + let lo = artifact_index(&rotation_manifest, "rotation-02-lo"); + let mut dot_alias = rotation_manifest.clone(); + dot_alias["artifacts"][lo]["relativePath"] = + json!("evidence/server-sup-sync/site/current/./wsyncmgr.log"); + dot_alias["artifacts"][lo]["bytesCopied"] = + dot_alias["artifacts"][current]["bytesCopied"].clone(); + if mutation_was_accepted("rotation-boundary", &dot_alias, &rotation_expected) { + accepted.push("dot-segment path alias reused a physical evidence destination"); + } + + let wcm = artifact_index(&success_manifest, "sync-success-01-wcm"); + let mut unsafe_source_path = success_manifest.clone(); + unsafe_source_path["artifacts"][wcm]["sanitizedSourcePath"] = + json!("SYNTHETIC://configured-root/Site/Logs/../secrets.txt"); + if mutation_was_accepted("sync-success", &unsafe_source_path, &success_expected) { + accepted.push("sanitized source path accepted traversal syntax"); + } + + let mut empty_fingerprint = success_manifest.clone(); + empty_fingerprint["artifacts"][wcm]["pathFingerprint"] = json!("synthetic:"); + if mutation_was_accepted("sync-success", &empty_fingerprint, &success_expected) { + accepted.push("empty synthetic path fingerprint"); + } + + let mut empty_version = success_manifest.clone(); + empty_version["artifacts"][wcm]["sourceVersion"] = json!("5.00.TEST."); + if mutation_was_accepted("sync-success", &empty_version, &success_expected) { + accepted.push("empty synthetic source-version suffix"); + } + + let mut non_string_role = success_manifest.clone(); + non_string_role["topology"]["rolesObserved"] = + json!(["siteServer", "softwareUpdatePoint", 7, "wsUs"]); + if mutation_was_accepted("sync-success", &non_string_role, &success_expected) { + accepted.push("non-string topology role"); + } + + let mut non_string_state = success_expected.clone(); + non_string_state["stateChain"] + .as_array_mut() + .expect("state chain is mutable") + .push(json!(7)); + if mutation_was_accepted("sync-success", &success_manifest, &non_string_state) { + accepted.push("non-string state-chain entry"); + } + + let mut non_string_gap = success_expected.clone(); + non_string_gap["transactions"][transaction]["coverageGapArtifactIds"] + .as_array_mut() + .expect("coverage gaps are mutable") + .push(json!(7)); + if mutation_was_accepted("sync-success", &success_manifest, &non_string_gap) { + accepted.push("non-string transaction coverage-gap ID"); + } + + let mut non_string_source_local_artifact = rotation_expected.clone(); + let rotation_split = source_local_index(&rotation_expected, "rotation-01-split"); + non_string_source_local_artifact["sourceLocalObservations"][rotation_split]["artifactIds"] + .as_array_mut() + .expect("source-local artifact IDs are mutable") + .push(json!(7)); + if mutation_was_accepted( + "rotation-boundary", + &rotation_manifest, + &non_string_source_local_artifact, + ) { + accepted.push("non-string source-local artifact ID"); + } + + assert!( + accepted.is_empty(), + "required-phase/schema/path mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn source_local_schema_identity_and_provenance_fail_closed() { + let rotation_manifest = + read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let rotation_expected = + read_json("rotation-boundary", "expected.json").expect("expected loads"); + let unrelated_manifest = + read_json("unrelated-update-key", "manifest.json").expect("manifest loads"); + let unrelated_expected = + read_json("unrelated-update-key", "expected.json").expect("expected loads"); + let rotation_split = source_local_index(&rotation_expected, "rotation-01-split"); + let malformed = source_local_index(&rotation_expected, "rotation-02-malformed"); + let ignored_client = source_local_index(&unrelated_expected, "unrelated-client-01"); + let mut accepted = Vec::new(); + + let mut renamed_observation = rotation_expected.clone(); + renamed_observation["sourceLocalObservations"][rotation_split]["observationId"] = + json!("rotation-01-arbitrary"); + if mutation_was_accepted( + "rotation-boundary", + &rotation_manifest, + &renamed_observation, + ) { + accepted.push("source-local observation identity was renamed"); + } + + let mut duplicate_observation_id = rotation_expected.clone(); + duplicate_observation_id["sourceLocalObservations"][rotation_split]["observationId"] = + duplicate_observation_id["sourceLocalObservations"][malformed]["observationId"].clone(); + if mutation_was_accepted( + "rotation-boundary", + &rotation_manifest, + &duplicate_observation_id, + ) { + accepted.push("source-local observation identity was duplicated"); + } + + let mut unknown_artifact = rotation_expected.clone(); + unknown_artifact["sourceLocalObservations"][rotation_split]["artifactIds"] + .as_array_mut() + .expect("source-local artifact IDs are mutable") + .insert(0, json!("aaa-unknown-artifact")); + if mutation_was_accepted("rotation-boundary", &rotation_manifest, &unknown_artifact) { + accepted.push("source-local observation cited an unknown artifact ID"); + } + + let mut non_array_evidence = rotation_expected.clone(); + non_array_evidence["sourceLocalObservations"][rotation_split]["evidence"] = + json!("not-an-array"); + if mutation_was_accepted("rotation-boundary", &rotation_manifest, &non_array_evidence) { + accepted.push("source-local evidence accepted a non-array value"); + } + + let mut duplicate_evidence = unrelated_expected.clone(); + let duplicate_reference = + duplicate_evidence["sourceLocalObservations"][ignored_client]["evidence"][0].clone(); + duplicate_evidence["sourceLocalObservations"][ignored_client]["evidence"] + .as_array_mut() + .expect("source-local evidence is mutable") + .push(duplicate_reference); + if mutation_was_accepted( + "unrelated-update-key", + &unrelated_manifest, + &duplicate_evidence, + ) { + accepted.push("source-local observation cited one logical record twice"); + } + + assert!( + accepted.is_empty(), + "source-local schema/identity/provenance mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn partial_capture_malformed_bytes_and_rotation_family_fail_closed() { + let success_manifest = read_json("sync-success", "manifest.json").expect("manifest loads"); + let success_expected = read_json("sync-success", "expected.json").expect("expected loads"); + let rotation_manifest = + read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let rotation_expected = + read_json("rotation-boundary", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut incomplete_required_rotation = success_manifest.clone(); + incomplete_required_rotation["artifacts"] + .as_array_mut() + .expect("manifest artifacts are mutable") + .push(json!({ + "artifactId": "sync-success-04-wsync-partial", + "sourceId": "server-sup-sync", + "producerRole": "siteServer", + "producerHostHandle": EXACT_SITE_SERVER, + "workflowSubjectRole": "softwareUpdatePoint", + "workflowSubjectHandle": EXACT_SUP, + "sourceKind": "ccmLog", + "originalBasename": "wsyncmgr.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/Site/Logs/wsyncmgr.log.1", + "pathFingerprint": "synthetic:sync-success-wsync-partial", + "rotation": { + "kind": "numbered", + "value": 1, + "lineageId": "sync-success-wsync", + "fragmentComplete": false + }, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T18:00:00Z", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "bytesCopied": 182, + "relativePath": "evidence/server-sup-sync/site/numbered-1/wsyncmgr.log" + })); + let mut incomplete_required_expected = success_expected.clone(); + incomplete_required_expected["coverage"] + .as_array_mut() + .expect("coverage is mutable") + .push(json!({ + "artifactId": "sync-success-04-wsync-partial", + "state": "captured" + })); + if mutation_was_accepted_with_asset( + "sync-success", + &incomplete_required_rotation, + &incomplete_required_expected, + "evidence/server-sup-sync/site/numbered-1/wsyncmgr.log", + "incomplete-required-numbered-wsyncmgr.log", + ) { + accepted.push("captured incomplete required rotation retained high-confidence success"); + } + + let malformed = artifact_index(&rotation_manifest, "rotation-03-malformed"); + let mut parse_failed_valid_ccm = rotation_manifest.clone(); + parse_failed_valid_ccm["artifacts"][malformed]["sanitizedSourcePath"] = + json!("SYNTHETIC://configured-root/SUP/Logs/WSUSCtrl.log.1"); + parse_failed_valid_ccm["artifacts"][malformed]["rotation"]["kind"] = json!("numbered"); + parse_failed_valid_ccm["artifacts"][malformed]["rotation"]["value"] = json!(1); + parse_failed_valid_ccm["artifacts"][malformed]["bytesCopied"] = json!(326); + parse_failed_valid_ccm["artifacts"][malformed]["relativePath"] = + json!("evidence/server-sup-sync/sup/numbered-1/WSUSCtrl.log"); + if mutation_was_accepted_with_asset( + "rotation-boundary", + &parse_failed_valid_ccm, + &rotation_expected, + "evidence/server-sup-sync/sup/numbered-1/WSUSCtrl.log", + "parse-failed-valid-numbered-wsusctrl.log", + ) { + accepted.push("parse-failed artifact contained usable normalized CCM evidence"); + } + + let lo = artifact_index(&rotation_manifest, "rotation-02-lo"); + let mut cross_family_rotation = rotation_manifest.clone(); + cross_family_rotation["artifacts"][lo]["originalBasename"] = json!("WCM.log"); + cross_family_rotation["artifacts"][lo]["sanitizedSourcePath"] = + json!("SYNTHETIC://configured-root/Site/Logs/WCM.lo_"); + cross_family_rotation["artifacts"][lo]["relativePath"] = + json!("evidence/server-sup-sync/site/lo_/WCM.log"); + if mutation_was_accepted_with_asset( + "rotation-boundary", + &cross_family_rotation, + &rotation_expected, + "evidence/server-sup-sync/site/lo_/WCM.log", + "cross-family-lo-wcm.log", + ) { + accepted.push("rotation split grouped different canonical log families"); + } + + assert!( + accepted.is_empty(), + "partial/malformed/rotation-family mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn source_versions_must_match_the_selected_extraction_profile() { + let manifest = read_json("sync-success", "manifest.json").expect("manifest loads"); + let expected = read_json("sync-success", "expected.json").expect("expected loads"); + validate_scenario_values("sync-success", &manifest, &expected) + .expect("the declared synthetic source version remains selected"); + + let mut accepted = Vec::new(); + let mut unknown_profile = manifest.clone(); + for artifact in unknown_profile["artifacts"] + .as_array_mut() + .expect("artifacts are mutable") + { + artifact["sourceVersion"] = json!("5.00.TEST.UNKNOWN"); + } + if mutation_was_accepted("sync-success", &unknown_profile, &expected) { + accepted.push("unknown source versions retained the selected profile"); + } + + let mut mixed_versions = manifest; + let wcm = artifact_index(&mixed_versions, "sync-success-01-wcm"); + mixed_versions["artifacts"][wcm]["sourceVersion"] = json!("5.00.TEST.0002"); + if mutation_was_accepted("sync-success", &mixed_versions, &expected) { + accepted.push("mixed source versions retained one exact transaction"); + } + + assert!( + accepted.is_empty(), + "source-version/profile mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn bounded_request_documentation_includes_nonphysical_manifest_coverage() { + let contract = + include_str!("../../../docs/sccm/preparation/issue-330-software-update-point-corpus.md"); + assert!( + contract.contains("backed by matching noncomplete manifest coverage"), + "bounded request prose must include absent/access-denied manifest states" + ); + assert!( + !contract.contains("backed by matching noncomplete physical coverage"), + "bounded request prose must not require physical evidence for nonphysical states" + ); + assert!( + !contract.contains("incomplete physical coverage"), + "coverage prose must include nonphysical manifest states" + ); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs new file mode 100644 index 000000000..c9173af89 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs @@ -0,0 +1,339 @@ +use serde_json::Value; + +fn site_core_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/server/site_core") +} + +fn site_core_manifests() -> Vec<(String, Value)> { + let mut scenario_dirs = std::fs::read_dir(site_core_root()) + .expect("site-core fixture root is readable") + .map(|entry| entry.expect("site-core directory entry is readable").path()) + .filter(|path| path.is_dir()) + .collect::>(); + scenario_dirs.sort(); + + scenario_dirs + .into_iter() + .map(|scenario_dir| { + let scenario = scenario_dir + .file_name() + .expect("scenario directory has a name") + .to_string_lossy() + .into_owned(); + let contents = std::fs::read_to_string(scenario_dir.join("manifest.json")) + .expect("scenario manifest is readable"); + let manifest = + serde_json::from_str(&contents).expect("scenario manifest contains valid JSON"); + (scenario, manifest) + }) + .collect() +} + +fn coverage_contract_failures(artifact: &Value) -> Vec { + let state = artifact["captureState"].as_str().unwrap_or_default(); + if matches!( + state, + "absent" | "accessDenied" | "capped" | "skipped" | "unsupported" | "parseFailed" + ) && artifact["fragmentComplete"] == true + { + vec![format!( + "{state} artifact {} cannot be a complete fragment", + artifact["artifactId"].as_str().unwrap_or("") + )] + } else { + Vec::new() + } +} + +fn artifact_storage_failures(scenario_dir: &std::path::Path, artifact: &Value) -> Vec { + let mut failures = Vec::new(); + let artifact_id = artifact["artifactId"].as_str().unwrap_or(""); + let state = artifact["captureState"].as_str().unwrap_or_default(); + + if matches!(state, "captured" | "capped") { + let Some(relative_path) = artifact["relativePath"].as_str() else { + return vec![format!( + "{state} artifact {artifact_id} must have a relativePath" + )]; + }; + let relative = std::path::Path::new(relative_path); + if relative.is_absolute() + || relative.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + failures.push(format!( + "{state} artifact {artifact_id} has an unsafe relativePath {relative_path}" + )); + return failures; + } + + let fixture_path = scenario_dir.join(relative); + if !fixture_path.is_file() { + failures.push(format!( + "{state} artifact {artifact_id} path does not resolve to a fixture: {}", + fixture_path.display() + )); + return failures; + } + + let Some(bytes_copied) = artifact["bytesCopied"].as_u64() else { + failures.push(format!( + "{state} artifact {artifact_id} must record bytesCopied" + )); + return failures; + }; + let actual_bytes = std::fs::metadata(&fixture_path) + .expect("validated fixture metadata is readable") + .len(); + if bytes_copied != actual_bytes { + failures.push(format!( + "{state} artifact {artifact_id} bytesCopied {bytes_copied} does not match fixture length {actual_bytes}" + )); + } + } else if matches!( + state, + "absent" | "accessDenied" | "skipped" | "unsupported" | "parseFailed" + ) { + if !artifact["relativePath"].is_null() { + failures.push(format!( + "{state} artifact {artifact_id} cannot have a relativePath" + )); + } + if artifact["bytesCopied"].as_u64() != Some(0) { + failures.push(format!( + "{state} artifact {artifact_id} must record zero bytesCopied" + )); + } + } else { + failures.push(format!( + "artifact {artifact_id} has missing or unknown captureState {state:?}" + )); + } + + failures +} + +#[test] +fn site_core_uses_canonical_rotation_and_coverage_contracts() { + let manifests = site_core_manifests(); + assert_eq!(manifests.len(), 9, "site-core scenario matrix changed"); + + let mut failures = Vec::new(); + let mut artifacts_seen = 0; + let mut physical_artifacts = 0; + for (scenario, manifest) in &manifests { + let site_code = manifest["topology"]["siteCode"] + .as_str() + .expect("site-core topology has a site code"); + if site_code.len() != 3 + || !site_code + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + failures.push(format!( + "{scenario}: siteCode must match ^[A-Z0-9]{{3}}$, got {site_code}" + )); + } + + for artifact in manifest["artifacts"] + .as_array() + .expect("site-core artifacts are an array") + { + artifacts_seen += 1; + if matches!( + artifact["captureState"].as_str(), + Some("captured" | "capped") + ) { + physical_artifacts += 1; + } + failures.extend( + coverage_contract_failures(artifact) + .into_iter() + .map(|failure| format!("{scenario}: {failure}")), + ); + failures.extend( + artifact_storage_failures(&site_core_root().join(scenario), artifact) + .into_iter() + .map(|failure| format!("{scenario}: {failure}")), + ); + } + } + assert_eq!(artifacts_seen, 18, "site-core artifact matrix changed"); + assert_eq!( + physical_artifacts, 14, + "site-core physical artifact matrix changed" + ); + + let rotations = manifests + .iter() + .find(|(scenario, _)| scenario == "rotation-boundary") + .map(|(_, manifest)| manifest) + .expect("site-core has a rotation-boundary scenario"); + let rollover = rotations["artifacts"] + .as_array() + .expect("rotation artifacts are an array") + .iter() + .find(|artifact| artifact["rotation"]["kind"] == "lo_") + .expect("rotation corpus has a .lo_ artifact"); + + let basename = rollover["originalBasename"] + .as_str() + .expect("rollover artifact has an original basename"); + if basename != "sitecomp.lo_" { + failures.push(format!( + "rotation-boundary: standard ConfigMgr rollover basename must be sitecomp.lo_, got {basename}" + )); + } + let relative_path = rollover["relativePath"] + .as_str() + .expect("captured rollover has a relative path"); + if !relative_path.ends_with("/sitecomp.lo_") { + failures.push(format!( + "rotation-boundary: rollover relativePath must end in /sitecomp.lo_, got {relative_path}" + )); + } + let expected: Value = serde_json::from_str(include_str!( + "fixtures/sccm/server/site_core/rotation-boundary/expected.json" + )) + .expect("rotation expected output is JSON"); + let requested_candidates = expected["unlinkedObservations"] + .as_array() + .expect("rotation output has observations") + .iter() + .flat_map(|observation| { + observation["nextArtifacts"] + .as_array() + .into_iter() + .flatten() + }) + .flat_map(|request| request["candidates"].as_array().into_iter().flatten()) + .collect::>(); + let rollover_candidates = requested_candidates + .iter() + .filter(|candidate| candidate["basename"] == "sitecomp.lo_") + .copied() + .collect::>(); + if requested_candidates + .iter() + .any(|candidate| candidate["basename"] == "sitecomp.log.lo_") + || rollover_candidates.len() != 1 + || rollover_candidates[0]["rotation"] != "loUnderscore" + { + failures.push(format!( + "rotation-boundary: expected request must use exactly paired sitecomp.lo_/loUnderscore, got {requested_candidates:?}" + )); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn capped_artifact_cannot_claim_a_complete_fragment() { + let artifact = serde_json::json!({ + "artifactId": "capped-probe", + "captureState": "capped", + "rotation": {"kind": "current"}, + "fragmentComplete": true + }); + + assert_eq!(coverage_contract_failures(&artifact).len(), 1); +} + +#[test] +fn artifact_storage_contract_rejects_missing_mismatched_and_unsafe_paths() { + let scenario_dir = site_core_root().join("healthy"); + let wrong_size = serde_json::json!({ + "artifactId": "wrong-size", + "captureState": "captured", + "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "bytesCopied": 1 + }); + let missing = serde_json::json!({ + "artifactId": "missing", + "captureState": "captured", + "relativePath": "evidence/sccm/server/site-core/sitecomp/current/missing.log", + "bytesCopied": 1 + }); + let unsafe_path = serde_json::json!({ + "artifactId": "unsafe", + "captureState": "captured", + "relativePath": "../outside.log", + "bytesCopied": 1 + }); + let missing_bytes = serde_json::json!({ + "artifactId": "missing-bytes", + "captureState": "captured", + "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log" + }); + assert_eq!( + artifact_storage_failures(&scenario_dir, &wrong_size).len(), + 1 + ); + assert_eq!(artifact_storage_failures(&scenario_dir, &missing).len(), 1); + assert_eq!( + artifact_storage_failures(&scenario_dir, &unsafe_path).len(), + 1 + ); + assert_eq!( + artifact_storage_failures(&scenario_dir, &missing_bytes).len(), + 1 + ); +} + +#[test] +fn nonphysical_states_cannot_claim_files_or_complete_fragments() { + let scenario_dir = site_core_root().join("healthy"); + for state in [ + "absent", + "accessDenied", + "skipped", + "unsupported", + "parseFailed", + ] { + let artifact = serde_json::json!({ + "artifactId": format!("{state}-with-file"), + "captureState": state, + "relativePath": "evidence/placeholder.log", + "bytesCopied": 1, + "rotation": {"kind": "current"}, + "fragmentComplete": true + }); + + assert_eq!( + coverage_contract_failures(&artifact).len(), + 1, + "{state} completeness" + ); + assert_eq!( + artifact_storage_failures(&scenario_dir, &artifact).len(), + 2, + "{state} physical storage" + ); + } +} + +#[test] +fn missing_and_unknown_capture_states_fail_closed() { + let scenario_dir = site_core_root().join("healthy"); + for artifact in [ + serde_json::json!({ + "artifactId": "missing-state", + "relativePath": null, + "bytesCopied": 0 + }), + serde_json::json!({ + "artifactId": "misspelled-state", + "captureState": "caputred", + "relativePath": null, + "bytesCopied": 0 + }), + ] { + assert_eq!(artifact_storage_failures(&scenario_dir, &artifact).len(), 1); + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs new file mode 100644 index 000000000..d71cb4dd8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -0,0 +1,7606 @@ +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, + SccmRotation, SccmSignal, SccmSignalKind, SccmTerminalEvidence, SccmTerminalEvidenceKind, + SccmTimeOrderingState, SccmTimestamp, SccmUnknownRotation, + MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS, MAX_SCCM_NEXT_ARTIFACT_REQUESTS, + SCCM_DIAGNOSTICS_SCHEMA_VERSION, +}; + +fn client_policy_artifact() -> SccmArtifact { + SccmArtifact { + artifact_id: "client-policy-agent".into(), + display_name: "PolicyAgent.log".into(), + original_path: Some(r"C:\Windows\CCM\Logs\PolicyAgent.log".into()), + host: Some("LAB-CLIENT-01".into()), + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1007".into()), + collected_at_utc: Some("2026-07-30T15:00:00Z".into()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".into()), + } +} + +fn evidence_with_message(message: &str) -> SccmEvidence { + SccmEvidence { + evidence_id: "client-policy-agent:1-1".into(), + reference: SccmEvidenceRef { + artifact_id: "client-policy-agent".into(), + entry_id: "client-policy-agent:1-1".into(), + line_start: Some(1), + line_end: Some(1), + }, + role: SccmRole::Client, + component: Some("PolicyAgent".into()), + ccm_source_file: Some("policyagent.cpp".into()), + message: message.into(), + timestamp: SccmTimestamp { + original_display: None, + offset_minutes: None, + utc_millis: None, + ordering_state: SccmTimeOrderingState::TimestampMissing, + }, + execution_context: None, + } +} + +fn finding_evidence_ref(artifact_id: &str, entry_id: &str) -> SccmEvidenceRef { + SccmEvidenceRef { + artifact_id: artifact_id.into(), + entry_id: entry_id.into(), + line_start: Some(1), + line_end: Some(1), + } +} + +fn finding_key( + kind: SccmCorrelationKeyKind, + raw: &str, + normalized: &str, + confidence: SccmKeyConfidence, + extraction_profile_id: Option<&str>, + evidence: SccmEvidenceRef, +) -> SccmCorrelationKey { + SccmCorrelationKey { + kind, + raw: raw.into(), + normalized: normalized.into(), + confidence, + extraction_profile_id: extraction_profile_id.map(str::to_owned), + evidence: Some(evidence), + start: None, + end: None, + } +} + +/// Evidence-reference payloads that `validate_evidence_reference` rejects. +/// +/// Every door that admits a reference, standalone or nested, must reject all +/// of them. Shared so a nested door cannot be tested against a weaker list +/// than the standalone door. +fn noncanonical_evidence_ref_payloads() -> Vec<(&'static str, serde_json::Value)> { + let canonical = serde_json::to_value(finding_evidence_ref("artifact-a", "entry-a")).unwrap(); + let mut payloads: Vec<(&'static str, serde_json::Value)> = Vec::new(); + + for (label, field, value) in [ + ("a 5000-char artifact ID", "artifactId", "a".repeat(5000)), + ("an empty artifact ID", "artifactId", String::new()), + ("an untrimmed entry ID", "entryId", " entry-a ".to_owned()), + ("an empty entry ID", "entryId", String::new()), + ] { + let mut json = canonical.clone(); + json[field] = serde_json::json!(value); + payloads.push((label, json)); + } + + for (label, start, end) in [ + ( + "an inverted line range", + serde_json::json!(9), + serde_json::json!(2), + ), + ( + "a half-set line range", + serde_json::json!(7), + serde_json::Value::Null, + ), + ( + "a zero line start", + serde_json::json!(0), + serde_json::json!(1), + ), + ] { + let mut json = canonical.clone(); + json["lineStart"] = start; + json["lineEnd"] = end; + payloads.push((label, json)); + } + + payloads +} + +fn finding_client_gap(artifact_id: &str, coverage: SccmCoverageState) -> SccmFindingCoverageGap { + SccmFindingCoverageGap { + artifact_id: artifact_id.into(), + role: SccmRole::Client, + coverage, + } +} + +fn finding_request(logical_id: &str, role: SccmRole, reason: &str) -> SccmArtifactRequest { + SccmArtifactRequest { + logical_id: logical_id.into(), + role, + reason: reason.into(), + } +} + +fn finding_with_gap_and_request(finding_id: &str) -> SccmFinding { + SccmFindingBuilder::new(finding_id) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::AccessDenied, + )) + .next_artifact(finding_request( + "policyAgent", + SccmRole::Client, + "Confirm the bounded policy request outcome.", + )) + .build() + .unwrap() +} + +const ROOTED_ARTIFACT_REQUEST_REASONS: [&str; 7] = [ + r"Collect C:\ for related evidence.", + r"Collect C:\Windows\CCM\Logs\PolicyAgent.log.", + r"Collect C:/Windows/CCM/Logs/PolicyAgent.log.", + "Collect / for related evidence.", + "Collect /var/log/sccm/PolicyAgent.log.", + r"Collect \\server\share\PolicyAgent.log.", + "Collect //server/share/PolicyAgent.log.", +]; + +const COMPACT_UNBOUNDED_ARTIFACT_REQUEST_REASONS: [&str; 5] = [ + "Collect allfiles.", + "Collect everydirectory.", + "Scan entiredisk.", + "Search wholefilesystem.", + "Collect every log on the system.", +]; + +const ORDER_INDEPENDENT_UNBOUNDED_ARTIFACT_REQUEST_REASONS: [&str; 12] = [ + "Every log on the system must be collected.", + "Request all files on the system.", + "Obtain every directory from the machine.", + "Copy the entire filesystem for review.", + "Enumerate all folders under the client.", + "Download all logs from the device.", + "Archive the whole disk.", + "All files from every directory are required.", + "Collect all of the files.", + "Scan every single directory.", + "Collect PolicyAgent.log plus logs at root.", + "Collect PolicyAgent.log plus systemwide collection scope.", +]; + +const BOUNDED_NARRATIVE_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ + ("smsts", "Collect the full disk imaging Task Sequence log."), + ( + "policyAgent", + "Confirm the whole-disk encryption status recorded in PolicyAgent.log.", + ), + ( + "policyAgent", + "Confirm the system-wide assignment recorded in PolicyAgent.log.", + ), + ( + "policyAgent", + "Confirm recursive retry behavior recorded in PolicyAgent.log.", + ), + ( + "policyAgent", + "Confirm all files were downloaded, as recorded in PolicyAgent.log.", + ), +]; + +const REVIEW_UNBOUNDED_ARTIFACT_REQUEST_REASONS: [&str; 19] = [ + "System-wide logs are required.", + "Systemwide logs are required.", + "Drive-wide files are requested.", + "Logs from the filesystem root are required.", + "The filesystem root must be archived.", + "Recursive collection of system logs is required.", + "Recursive traversal of the filesystem is required.", + "All files were downloaded; collect them, as recorded in PolicyAgent.log.", + "Archive the whole disk; encryption is recorded in PolicyAgent.log.", + "Collect the full disk; imaging is recorded in Smsts.log.", + "Collect the full disk imaging Task Sequence log recursively across directories.", + "Collect the full disk imaging Task Sequence log and include recursive directory traversal.", + "Collect the full disk imaging Task Sequence log; include folders recursively.", + "Collect the full disk imaging Task Sequence log using recursion across folders.", + "Confirm all files were downloaded, as recorded in PolicyAgent.log, with recursive directory inclusion.", + "Confirm all files were downloaded, as recorded in PolicyAgent.log; recursive traversal across directories is also required.", + "Collect the full disk imaging Task Sequence log plus system-wide logs.", + "Collect the full disk imaging Task Sequence log plus systemwide logs.", + "Confirm all files were downloaded, as recorded in PolicyAgent.log, plus logs from the filesystem root.", +]; + +const REVIEW_BOUNDED_NAMED_ARTIFACT_REQUESTS: [(&str, &str); 14] = [ + ("policyAgent", "Collect the complete PolicyAgent.log file."), + ("policyAgent", "Collect every cited PolicyAgent.log entry."), + ( + "policyAgent", + "Confirm all assignment IDs in the cited PolicyAgent.log record.", + ), + ( + "dataTransferService", + "Confirm complete file download status in DataTransferService.log.", + ), + ("policyAgent", "Collect all rotations of PolicyAgent.log."), + ( + "policyAgent", + "Confirm every retry in PolicyAgent.log for assignment A.", + ), + ( + "policyAgent", + "Collect the whole PolicyAgent.log record cited by entry A.", + ), + ( + "policyAgent", + "Confirm the full client policy in PolicyAgent.log.", + ), + ("smsts", "Collect the complete Task Sequence log."), + ( + "dataTransferService", + "Confirm all content files were downloaded, as recorded in DataTransferService.log.", + ), + ( + "dataTransferService", + "Confirm all of the files were downloaded, as recorded in DataTransferService.log.", + ), + ( + "dataTransferService", + "Confirm every file was downloaded, as recorded in DataTransferService.log.", + ), + ("smsts", "Confirm the full disk-image status in Smsts.log."), + ( + "smsts", + "Collect the complete disk imaging Task Sequence log.", + ), +]; + +const REVIEW_EXPANDED_UNBOUNDED_ARTIFACT_REQUEST_REASONS: [&str; 49] = [ + "Recursively; collect PolicyAgent.log.", + "Collect PolicyAgent.log. Recursively.", + "Collect PolicyAgent.log! Recursively.", + "Collect PolicyAgent.log; this request also applies recursively.", + "Collect PolicyAgent.log; use recursion.", + "Use recursion; collect PolicyAgent.log.", + "Collect PolicyAgent.log; recurse.", + "Recurse; collect PolicyAgent.log.", + "Collect PolicyAgent.log; scan the filesystem.", + "Scan the filesystem; collect PolicyAgent.log.", + "Collect PolicyAgent.log; traverse directories.", + "Collect PolicyAgent.log; enumerate the filesystem.", + "Collect PolicyAgent.log; archive the filesystem.", + "Collect PolicyAgent.log; search system logs.", + "Collect PolicyAgent.log; gather device logs.", + "Collect PolicyAgent.log; capture machine files.", + "Collect PolicyAgent.log; logs from the machine.", + "Collect PolicyAgent.log; across the filesystem.", + "Collect PolicyAgent.log; throughout the system.", + "Collect PolicyAgent.log; from root.", + "At root; collect PolicyAgent.log.", + "Collect PolicyAgent.log; device root.", + "Collect PolicyAgent.log; systemwide.", + "System-wide; collect PolicyAgent.log.", + "Collect PolicyAgent.log; sitewide.", + "Collect PolicyAgent.log; across all systems.", + "Collect PolicyAgent.log; from every machine.", + "Collect PolicyAgent.log; the entire system.", + "Collect PolicyAgent.log; all data.", + "Collect PolicyAgent.log; all records on the system.", + "Collect PolicyAgent.log; everything from the system.", + "Collect PolicyAgent.log; capture everything.", + "Collect PolicyAgent.log; download all data.", + "Collect PolicyAgent.log; collect related files.", + "Collect all disks, status is recorded in PolicyAgent.log.", + "Collect every drive, encryption status is recorded in PolicyAgent.log.", + "Collect all files, download status is recorded in PolicyAgent.log.", + "Collect the whole filesystem status from PolicyAgent.log.", + "Collect complete machine status files recorded in PolicyAgent.log.", + "Collect C:.", + "Collect D: for evidence.", + "Collect %SYSTEMROOT%.", + "Collect %WINDIR%.", + "Collect %SystemDrive%.", + "Collect $env:SystemRoot.", + "Collect ../PolicyAgent.log.", + r"Collect ..\PolicyAgent.log.", + "Collect Logs/../PolicyAgent.log.", + "Collect PolicyAgent.log; recursively.", +]; + +const REVIEW_LOOKALIKE_ARTIFACT_REQUEST_REASONS: [&str; 4] = [ + "Collect every PolicyAgent-backup.log file.", + "Collect every PolicyAgent.log.backup file.", + "Collect every PolicyAgent—backup.log file.", + "Collect every PolicyAgent backup file.", +]; + +const REVIEW_UNQUALIFIED_COLLECTION_ACTION_REASONS: [&str; 15] = [ + "Archive diagnostics.zip.", + "Capture the registry.", + "Collect unrelated.log.", + "Copy database.db.", + "Download package.bin.", + "Enumerate registry keys.", + "Export credentials.json.", + "Gather diagnostics.", + "Inspect arbitrary.txt.", + "Obtain secrets.txt.", + "Read config.ini.", + "Scan unrelated.log.", + "Search temp files.", + "Traverse cache.", + "Walk the directory tree.", +]; + +const REVIEW_COORDINATED_UNQUALIFIED_ACTION_REASONS: [&str; 3] = [ + "Collect PolicyAgent.log and archive.", + "Collect PolicyAgent.log then scan.", + "Collect PolicyAgent.log plus export.", +]; + +const REVIEW_UNBOUND_COLLECTION_TARGET_REASONS: [&str; 10] = [ + "Collect secrets.txt because PolicyAgent.log reported an error.", + "Collect PolicyAgent.log and retrieve secrets.txt.", + "Collect credentials.json since PolicyAgent.log recorded a failure.", + "Collect evidence.zip after PolicyAgent.log reported a failure.", + "Collect credentials because PolicyAgent.log reported an error.", + "Collect PolicyAgent.log then retrieve credentials.json.", + "Collect PolicyAgent.log plus fetch credentials.json.", + "Collect PolicyAgent.log and preserve secrets.txt.", + "Collect PolicyAgent.log, retrieve secrets.txt.", + "Collect PolicyAgent.log and retrieve credentials.", +]; + +const REVIEW_SAFE_COLLECTION_NARRATIVE_REASONS: [&str; 4] = [ + "Collect PolicyAgent.log because the policy evaluation reported an error.", + "Collect PolicyAgent.log for review of the reported assignment error.", + "Collect PolicyAgent.log after the reported policy error.", + "Policy evidence was not captured.", +]; + +const REVIEW_NON_AUTHORIZING_COLLECTION_LANGUAGE_REASONS: [&str; 6] = [ + "Collect PolicyAgent.log; retrieve secrets.txt.", + "Collect PolicyAgent.log. Fetch credentials.", + "Collect PolicyAgent.log; preserve secrets.txt.", + "Collect PolicyAgent.log for collection of credentials.", + "Collect PolicyAgent.log because secrets must be copied.", + "Collect PolicyAgent.log after credentials were archived.", +]; + +const REVIEW_SAFE_STRONG_PUNCTUATION_NARRATIVE_REASONS: [&str; 3] = [ + "Collect PolicyAgent.log; policy evidence was not captured.", + "Collect PolicyAgent.log. The policy evaluation reported an error.", + "Collect PolicyAgent.log; the assignment error was reported.", +]; + +const REVIEW_EVIDENCE_SUBJECT_UNBOUND_PASSIVE_REASONS: [&str; 3] = [ + "Collect PolicyAgent.log; policy evidence must include credentials.", + "Collect PolicyAgent.log; policy evidence needs credentials.", + "Collect PolicyAgent.log. Policy evidence needs to include secrets.", +]; + +const REVIEW_STANDALONE_AND_INFLECTED_COLLECTION_REASONS: [&str; 6] = [ + "Retrieve secrets.txt.", + "Fetch credentials.json.", + "Preserve secrets.txt.", + "Acquire credentials.json.", + "Collect PolicyAgent.log for fetching credentials.", + "Collect PolicyAgent.log after retrieving credentials.", +]; + +const REVIEW_UNRECOGNIZED_CONFIRMATION_REQUEST_REASONS: [&str; 10] = [ + "Confirm retrieve secrets.txt.", + "Confirm acquire credentials.json.", + "Confirm fetch credentials.json.", + "Confirm preserve secrets.txt.", + "Confirm retrieving credentials.", + "Confirm fetching secrets.", + "Confirm preservation of secrets.txt.", + "Confirm collection of credentials.json.", + "Confirm PolicyAgent.log after retrieving credentials.", + "Confirm PolicyAgent.log for fetching credentials.", +]; + +const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 36] = [ + "Confirm all files are required for status in PolicyAgent.log.", + "Confirm every file must be provided for download status in PolicyAgent.log.", + "Confirm the whole disk is required for imaging status in Smsts.log.", + "Confirm the full disk must be provided for imaging status in Smsts.log.", + "Confirm PolicyAgent.log status must include all files.", + "Confirm Smsts.log imaging status must provide the full disk.", + "Confirm PolicyAgent.log status must have all files provided.", + "Confirm Smsts.log imaging status must have the full disk provided.", + "Confirm PolicyAgent.log status has all files provided.", + "Confirm PolicyAgent.log status has every file provided.", + "Confirm all files have provided status in PolicyAgent.log.", + "Confirm Smsts.log imaging status has the full disk provided.", + "Confirm PolicyAgent.log status had every file.", + "Confirm PolicyAgent.log status has all files.", + "Confirm PolicyAgent.log status have every file.", + "Confirm PolicyAgent.log status are all files.", + "Confirm PolicyAgent.log status be all files.", + "Confirm PolicyAgent.log status been all files.", + "Confirm PolicyAgent.log status being all files.", + "Confirm Smsts.log imaging status is the full disk.", + "Confirm Smsts.log imaging status was the full disk.", + "Confirm Smsts.log imaging status were the full disk.", + "Confirm Smsts.log imaging status has the full disk.", + "Confirm every file had status in PolicyAgent.log.", + "Confirm every file has status in PolicyAgent.log.", + "Confirm all files have status in PolicyAgent.log.", + "Confirm all files are in PolicyAgent.log status.", + "Confirm all files be in PolicyAgent.log status.", + "Confirm all files have been in PolicyAgent.log status.", + "Confirm all files are being in PolicyAgent.log status.", + "Confirm the full disk is in Smsts.log imaging status.", + "Confirm the full disk was in Smsts.log imaging status.", + "Confirm the full disk were in Smsts.log imaging status.", + "Confirm the full disk has imaging status in Smsts.log.", + "Confirm all files, have status in PolicyAgent.log.", + "Confirm the full disk, has imaging status in Smsts.log.", +]; + +const REVIEW_BOUNDED_AUXILIARY_CONFIRMATION_REASONS: [(&str, &str); 10] = [ + ( + "policyAgent", + "Confirm PolicyAgent.log status had evidence.", + ), + ( + "policyAgent", + "Confirm PolicyAgent.log status has evidence.", + ), + ("policyAgent", "Confirm PolicyAgent.log files have status."), + ( + "policyAgent", + "Confirm PolicyAgent.log files are downloaded.", + ), + ( + "policyAgent", + "Confirm PolicyAgent.log status should be available.", + ), + ( + "policyAgent", + "Confirm PolicyAgent.log status has been available.", + ), + ( + "policyAgent", + "Confirm PolicyAgent.log status is being reported.", + ), + ( + "policyAgent", + "Confirm PolicyAgent.log status is available.", + ), + ( + "policyAgent", + "Confirm PolicyAgent.log status was available.", + ), + ( + "policyAgent", + "Confirm PolicyAgent.log files were downloaded.", + ), +]; + +const REVIEW_EXACT_MP_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ + ("mpCliReg", "Collect the complete MP_CliReg.log file."), + ("mpGetAuth", "Collect the complete MP_GetAuth.log file."), + ("mpGetPolicy", "Collect the complete MP_GetPolicy.log file."), + ("mpLocation", "Collect the complete MP_Location.log file."), + ( + "mpRegistrationManager", + "Collect the complete MP_RegistrationManager.log file.", + ), +]; + +#[derive(Clone, Copy, Debug)] +enum FindingEvidenceAliasSurface { + TopLevel, + Terminal, + CorrelationKey, +} + +fn finding_with_evidence_alias( + finding_id: &str, + alias_surface: Option, +) -> Result { + let mut top_level = finding_evidence_ref("artifact-a", "entry-a"); + let mut terminal = top_level.clone(); + let mut key = top_level.clone(); + match alias_surface { + Some(FindingEvidenceAliasSurface::TopLevel) => { + top_level.artifact_id = " artifact-a ".into(); + top_level.entry_id = " entry-a ".into(); + } + Some(FindingEvidenceAliasSurface::Terminal) => { + terminal.artifact_id = " artifact-a ".into(); + terminal.entry_id = " entry-a ".into(); + } + Some(FindingEvidenceAliasSurface::CorrelationKey) => { + key.artifact_id = " artifact-a ".into(); + key.entry_id = " entry-a ".into(); + } + None => {} + } + + SccmFindingBuilder::new(finding_id) + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![top_level]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(terminal)]) + .correlation_keys(vec![finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + key, + )]) + .build() +} + +fn apply_evidence_alias(finding: &mut SccmFinding, surface: FindingEvidenceAliasSurface) { + match surface { + FindingEvidenceAliasSurface::TopLevel => { + finding.evidence[0].artifact_id = " artifact-a ".into(); + finding.evidence[0].entry_id = " entry-a ".into(); + } + FindingEvidenceAliasSurface::Terminal => { + finding.terminal_evidence[0].reference.artifact_id = " artifact-a ".into(); + finding.terminal_evidence[0].reference.entry_id = " entry-a ".into(); + } + FindingEvidenceAliasSurface::CorrelationKey => { + let reference = finding.correlation_keys[0].evidence.as_mut().unwrap(); + reference.artifact_id = " artifact-a ".into(); + reference.entry_id = " entry-a ".into(); + } + } +} + +fn apply_evidence_alias_json( + finding: &mut serde_json::Value, + surface: FindingEvidenceAliasSurface, +) { + match surface { + FindingEvidenceAliasSurface::TopLevel => { + finding["evidence"][0]["artifactId"] = serde_json::json!(" artifact-a "); + finding["evidence"][0]["entryId"] = serde_json::json!(" entry-a "); + } + FindingEvidenceAliasSurface::Terminal => { + finding["terminalEvidence"][0]["reference"]["artifactId"] = + serde_json::json!(" artifact-a "); + finding["terminalEvidence"][0]["reference"]["entryId"] = serde_json::json!(" entry-a "); + } + FindingEvidenceAliasSurface::CorrelationKey => { + finding["correlationKeys"][0]["evidence"]["artifactId"] = + serde_json::json!(" artifact-a "); + finding["correlationKeys"][0]["evidence"]["entryId"] = serde_json::json!(" entry-a "); + } + } +} + +fn assert_evidence_alias_is_rejected(surface: FindingEvidenceAliasSurface) { + let mut mismatches = Vec::new(); + + if finding_with_evidence_alias("builder-evidence-alias", Some(surface)).err() + != Some(SccmFindingValidationError::InvalidEvidenceReference) + { + mismatches.push("builder did not return InvalidEvidenceReference"); + } + + let mut direct = finding_with_evidence_alias("direct-evidence-alias", None).unwrap(); + apply_evidence_alias(&mut direct, surface); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidEvidenceReference) { + mismatches.push("direct validate did not return InvalidEvidenceReference"); + } + if serde_json::to_value(&direct).is_ok() { + mismatches.push("validating serializer accepted the alias"); + } + + let canonical = finding_with_evidence_alias("deserialized-evidence-alias", None).unwrap(); + let mut json = serde_json::to_value(canonical).unwrap(); + apply_evidence_alias_json(&mut json, surface); + if serde_json::from_value::(json).is_ok() { + mismatches.push("deserializer accepted the alias"); + } + + assert!(mismatches.is_empty(), "{surface:?}: {mismatches:?}"); +} + +#[test] +fn finding_confirmed_failure_requires_terminal_evidence() { + let result = SccmFindingBuilder::new("app-enforcement-failed") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![finding_evidence_ref( + "client-app-enforce", + "client-app-enforce:1-1", + )]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::MissingTerminalEvidence + ); +} + +#[test] +fn finding_insufficient_evidence_requires_next_artifact_request() { + let result = SccmFindingBuilder::new("missing-policy-log") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::Absent, + )) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::MissingNextArtifactRequest + ); +} + +#[test] +fn finding_high_confirmed_failure_accepts_a_cited_terminal_failure() { + let evidence = finding_evidence_ref("client-app-enforce", "client-app-enforce:9-9"); + let finding = SccmFindingBuilder::new("app-enforcement-failed") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![evidence.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure( + evidence.clone(), + )]) + .build() + .unwrap(); + + assert_eq!(finding.evidence, vec![evidence.clone()]); + assert_eq!(finding.terminal_evidence[0].reference, evidence); + assert_eq!( + finding.terminal_evidence[0].kind, + SccmTerminalEvidenceKind::ObservedFailure + ); +} + +#[test] +fn finding_rejects_unknown_phase_values_that_shadow_declared_names() { + for phase in ["policy", "content", "enforcement"] { + let result = SccmFindingBuilder::new(format!("shadowed-{phase}")) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Unknown(phase.into())) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref( + "client-policy-agent", + "policy:1-1", + )]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::MissingRequiredField, + "{phase}" + ); + } +} + +#[test] +fn finding_forged_unregistered_profile_is_rejected() { + let first = finding_evidence_ref("client-policy-agent", "policy:10-10"); + let second = finding_evidence_ref("mp-get-policy", "mp-policy:20-20"); + let result = SccmFindingBuilder::new("policy-request-failed") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![second.clone(), first.clone()]) + .correlation_keys(vec![ + finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Exact, + Some("sccm-keys-stable-v1"), + first, + ), + finding_key( + SccmCorrelationKeyKind::AssignmentId, + "abcdefab-0000-0000-0000-000000000001", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Strong, + Some("sccm-keys-stable-v1"), + second, + ), + ]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidCorrelationKey + ); +} + +#[test] +fn finding_review_invalid_profiled_keys_fail_at_every_public_boundary() { + let evidence = finding_evidence_ref("artifact-a", "entry-a"); + let mut malformed = finding_key( + SccmCorrelationKeyKind::PackageId, + "", + "", + SccmKeyConfidence::Exact, + Some("sccm-keys-stable-v1"), + evidence.clone(), + ); + malformed.start = Some(9); + malformed.end = Some(3); + let cases = [ + ( + "exact-without-profile", + finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Exact, + None, + evidence.clone(), + ), + ), + ( + "strong-with-unknown-profile", + finding_key( + SccmCorrelationKeyKind::ContentId, + "ContentABC", + "contentabc", + SccmKeyConfidence::Strong, + Some("sccm-keys-unknown-v1"), + evidence.clone(), + ), + ), + ("malformed-values-and-spans", malformed), + ]; + let canonical = finding_with_gap_and_request("review-invalid-key-parity"); + let mut accepted = Vec::new(); + + for (label, key) in cases { + let builder = SccmFindingBuilder::new(format!("review-invalid-key-{label}")) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![evidence.clone()]) + .correlation_keys(vec![key.clone()]) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidCorrelationKey) { + accepted.push(format!( + "builder did not return InvalidCorrelationKey: {label}" + )); + } + + let mut direct = canonical.clone(); + direct.correlation_keys = vec![key.clone()]; + if direct.validate().err() != Some(SccmFindingValidationError::InvalidCorrelationKey) { + accepted.push(format!( + "direct validate did not return InvalidCorrelationKey: {label}" + )); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {label}")); + } + + let key_json = serde_json::json!({ + "kind": &key.kind, + "raw": &key.raw, + "normalized": &key.normalized, + "confidence": &key.confidence, + "extractionProfileId": &key.extraction_profile_id, + "evidence": &key.evidence, + "start": key.start, + "end": key.end, + }); + let mut json = serde_json::to_value(&canonical).unwrap(); + json["correlationKeys"] = serde_json::json!([key_json]); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {label}")); + } + } + + assert!( + accepted.is_empty(), + "accepted invalid profiled keys: {accepted:#?}" + ); +} + +#[test] +fn finding_unregistered_exact_duplicate_keys_are_rejected() { + let evidence = finding_evidence_ref("client-policy-agent", "policy:10-10"); + let key = finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Exact, + Some("sccm-keys-stable-v1"), + evidence.clone(), + ); + let result = SccmFindingBuilder::new("duplicated-corroboration") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![evidence]) + .correlation_keys(vec![key.clone(), key]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidCorrelationKey + ); +} + +#[test] +fn finding_same_minute_keyless_evidence_never_counts_as_high_confidence() { + let result = SccmFindingBuilder::new("same-minute-is-not-causation") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Content) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![ + finding_evidence_ref("client-content", "client-content:12:00"), + finding_evidence_ref("server-content", "server-content:12:00"), + ]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::MissingTerminalEvidence + ); +} + +#[test] +fn finding_unregistered_strong_or_exact_key_profiles_are_rejected() { + let first = finding_evidence_ref("client-content", "client-content:1-1"); + let second = finding_evidence_ref("server-content", "server-content:1-1"); + let cases = [ + ( + "mismatched-normalized-keys", + finding_key( + SccmCorrelationKeyKind::ContentId, + "ContentABC", + "contentabc", + SccmKeyConfidence::Strong, + Some("sccm-keys-stable-v1"), + first.clone(), + ), + finding_key( + SccmCorrelationKeyKind::ContentId, + "ContentXYZ", + "contentxyz", + SccmKeyConfidence::Strong, + Some("sccm-keys-stable-v1"), + second.clone(), + ), + ), + ( + "mismatched-key-profiles", + finding_key( + SccmCorrelationKeyKind::ContentId, + "ContentABC", + "contentabc", + SccmKeyConfidence::Strong, + Some("sccm-keys-stable-v1"), + first.clone(), + ), + finding_key( + SccmCorrelationKeyKind::ContentId, + "contentabc", + "contentabc", + SccmKeyConfidence::Exact, + Some("sccm-keys-stable-v2"), + second.clone(), + ), + ), + ]; + + for (finding_id, first_key, second_key) in cases { + let result = SccmFindingBuilder::new(finding_id) + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Content) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![first.clone(), second.clone()]) + .correlation_keys(vec![first_key, second_key]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidCorrelationKey, + "{finding_id}" + ); + } +} + +#[test] +fn finding_rejects_key_or_terminal_refs_that_are_not_cited() { + let cited = finding_evidence_ref("client-policy-agent", "policy:1-1"); + // `finding_evidence_ref` pins every span to line 1, so this reference used + // to claim line 1 while calling itself `policy:2-2`. Two entry ids over one + // physical line is the shape this suite now rejects outright, and it would + // mask the uncited-reference rule under test. The span is spelled out to + // match the entry id it advertises. + let missing = SccmEvidenceRef { + line_start: Some(2), + line_end: Some(2), + ..finding_evidence_ref("client-policy-agent", "policy:2-2") + }; + + let key_result = SccmFindingBuilder::new("uncited-key") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![cited.clone()]) + .correlation_keys(vec![finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + missing.clone(), + )]) + .build(); + assert_eq!( + key_result.unwrap_err(), + SccmFindingValidationError::CorrelationKeyEvidenceNotCited + ); + + let terminal_result = SccmFindingBuilder::new("uncited-terminal") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![cited]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(missing)]) + .build(); + assert_eq!( + terminal_result.unwrap_err(), + SccmFindingValidationError::TerminalEvidenceNotCited + ); +} + +#[test] +fn finding_nested_references_use_shared_identity_validation() { + let cited = finding_evidence_ref("client-policy-agent", "policy:1-1"); + let invalid = SccmEvidenceRef { + artifact_id: " ".into(), + entry_id: "policy:2-2".into(), + line_start: Some(2), + line_end: Some(2), + }; + + let terminal_result = SccmFindingBuilder::new("invalid-terminal-reference") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![cited.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure( + invalid.clone(), + )]) + .build(); + assert_eq!( + terminal_result.unwrap_err(), + SccmFindingValidationError::InvalidEvidenceReference + ); + + let key_result = SccmFindingBuilder::new("invalid-key-reference") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![cited]) + .correlation_keys(vec![finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + invalid, + )]) + .build(); + assert_eq!( + key_result.unwrap_err(), + SccmFindingValidationError::InvalidEvidenceReference + ); +} + +#[test] +fn finding_evidence_reference_line_ranges_are_integral() { + let cases = [ + ("missing-end", Some(1), None), + ("missing-start", None, Some(1)), + ("zero-start", Some(0), Some(1)), + ("reversed", Some(2), Some(1)), + ]; + + for (label, line_start, line_end) in cases { + let result = SccmFindingBuilder::new(format!("invalid-range-{label}")) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![SccmEvidenceRef { + artifact_id: "client-policy-agent".into(), + entry_id: "policy:1-1".into(), + line_start, + line_end, + }]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidEvidenceReference, + "{label}" + ); + } +} + +#[test] +fn finding_evidence_reference_allows_an_unavailable_line_range() { + let finding = SccmFindingBuilder::new("line-range-unavailable") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![SccmEvidenceRef { + artifact_id: "client-policy-agent".into(), + entry_id: "policy:logical-record".into(), + line_start: None, + line_end: None, + }]) + .build() + .unwrap(); + + serde_json::to_value(finding).unwrap(); +} + +#[test] +fn finding_serialization_canonicalizes_public_collection_mutation() { + let first = finding_evidence_ref("artifact-a", "entry-a"); + let second = finding_evidence_ref("artifact-b", "entry-b"); + let mut finding = SccmFindingBuilder::new("mutated-order") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![first.clone(), second.clone()]) + .terminal_evidence(vec![ + SccmTerminalEvidence::observed_failure(first.clone()), + SccmTerminalEvidence::observed_failure(second.clone()), + ]) + .coverage_gaps(vec![ + finding_client_gap("artifact-gap-a", SccmCoverageState::AccessDenied), + finding_client_gap("artifact-gap-b", SccmCoverageState::Capped), + ]) + .correlation_keys(vec![ + finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + first, + ), + finding_key( + SccmCorrelationKeyKind::PackageId, + "LAB00001", + "LAB00001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + second, + ), + ]) + .next_artifacts(vec![ + finding_request( + "policyAgent", + SccmRole::Client, + "Confirm the bounded policy request outcome.", + ), + finding_request( + "policyEvaluator", + SccmRole::Client, + "Confirm the bounded policy evaluation outcome.", + ), + ]) + .build() + .unwrap(); + + let expected = serde_json::to_value(&finding).unwrap(); + fn scramble(values: &mut Vec) { + values.reverse(); + values.push(values[0].clone()); + } + scramble(&mut finding.evidence); + scramble(&mut finding.terminal_evidence); + scramble(&mut finding.coverage_gaps); + scramble(&mut finding.correlation_keys); + scramble(&mut finding.next_artifacts); + + assert_eq!(serde_json::to_value(finding).unwrap(), expected); +} + +#[test] +fn finding_rejects_conflicting_ranges_for_one_logical_evidence_identity() { + let first = SccmEvidenceRef { + artifact_id: "artifact-a".into(), + entry_id: "entry-a".into(), + line_start: Some(1), + line_end: Some(1), + }; + let conflicting = SccmEvidenceRef { + line_start: Some(2), + line_end: Some(2), + ..first.clone() + }; + let top_level = SccmFindingBuilder::new("conflicting-top-level-ranges") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![first.clone(), conflicting.clone()]) + .build(); + let terminal = SccmFindingBuilder::new("conflicting-terminal-range") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![first.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure( + conflicting.clone(), + )]) + .build(); + let key = SccmFindingBuilder::new("conflicting-key-range") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![first]) + .correlation_keys(vec![finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + conflicting, + )]) + .build(); + + for (label, result) in [ + ("top-level", top_level), + ("terminal", terminal), + ("correlation-key", key), + ] { + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::ConflictingEvidenceReference, + "{label}" + ); + } + + let mut mutated = SccmFindingBuilder::new("mutated-conflicting-range") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![SccmEvidenceRef { + artifact_id: "artifact-a".into(), + entry_id: "entry-a".into(), + line_start: Some(1), + line_end: Some(1), + }]) + .build() + .unwrap(); + mutated.evidence.push(SccmEvidenceRef { + artifact_id: " artifact-a ".into(), + entry_id: " entry-a ".into(), + line_start: Some(2), + line_end: Some(2), + }); + assert_eq!( + mutated.validate().unwrap_err(), + SccmFindingValidationError::InvalidEvidenceReference + ); +} + +#[test] +fn finding_deserialization_prioritizes_conflicting_evidence_identity_ranges() { + let evidence = finding_evidence_ref("artifact-a", "entry-a"); + let finding = SccmFindingBuilder::new("conflicting-deserialized-range") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![evidence.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(evidence)]) + .build() + .unwrap(); + let mut json = serde_json::to_value(finding).unwrap(); + json["terminalEvidence"][0]["reference"]["lineStart"] = serde_json::json!(2); + json["terminalEvidence"][0]["reference"]["lineEnd"] = serde_json::json!(2); + + let error = serde_json::from_value::(json) + .unwrap_err() + .to_string(); + assert!(error.contains("ConflictingEvidenceReference"), "{error}"); +} + +#[test] +fn finding_serialization_prioritizes_conflicting_evidence_identity_ranges() { + let evidence = finding_evidence_ref("artifact-a", "entry-a"); + let mut finding = SccmFindingBuilder::new("conflicting-serialized-range") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![evidence.clone()]) + .correlation_keys(vec![finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + evidence, + )]) + .build() + .unwrap(); + let key_reference = finding.correlation_keys[0].evidence.as_mut().unwrap(); + key_reference.line_start = Some(2); + key_reference.line_end = Some(2); + + let error = serde_json::to_string(&finding).unwrap_err().to_string(); + assert!(error.contains("ConflictingEvidenceReference"), "{error}"); +} + +/// Builds one finding per citation surface, carrying `second` on that surface +/// only, so a validator that scans a single surface cannot pass by accident. +fn evidence_surface_findings( + label: &str, + first: &SccmEvidenceRef, + second: &SccmEvidenceRef, +) -> Vec<(String, Result)> { + fn scaffold(finding_id: String) -> SccmFindingBuilder { + SccmFindingBuilder::new(finding_id) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + } + + vec![ + ( + format!("{label}/top-level"), + scaffold(format!("{label}-top-level")) + .evidence(vec![first.clone(), second.clone()]) + .build(), + ), + ( + format!("{label}/terminal"), + scaffold(format!("{label}-terminal")) + .evidence(vec![first.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(second.clone())]) + .build(), + ), + ( + format!("{label}/correlation-key"), + scaffold(format!("{label}-correlation-key")) + .evidence(vec![first.clone()]) + .correlation_keys(vec![finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + second.clone(), + )]) + .build(), + ), + ] +} + +#[test] +fn finding_rejects_overlapping_ranges_across_distinct_evidence_identities() { + let first = SccmEvidenceRef { + artifact_id: "artifact-a".into(), + entry_id: "entry-a".into(), + line_start: Some(4), + line_end: Some(6), + }; + // Every case claims at least one physical line of `first` under a second + // entry id, so the two references cannot both be the record they claim. + let cases = [ + ("identical-span", Some(4), Some(6)), + ("shared-start-line", Some(1), Some(4)), + ("shared-end-line", Some(6), Some(9)), + ("contained-span", Some(5), Some(5)), + ("containing-span", Some(1), Some(9)), + ("straddling-span", Some(5), Some(9)), + ]; + + for (label, line_start, line_end) in cases { + let second = SccmEvidenceRef { + entry_id: "entry-b".into(), + line_start, + line_end, + ..first.clone() + }; + for (surface, result) in evidence_surface_findings(label, &first, &second) { + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::OverlappingEvidenceReference, + "{surface}" + ); + } + } +} + +#[test] +fn finding_overlap_rejection_survives_evidence_serde_round_trips() { + let first = SccmEvidenceRef { + artifact_id: "artifact-a".into(), + entry_id: "entry-a".into(), + line_start: Some(4), + line_end: Some(6), + }; + let mut finding = SccmFindingBuilder::new("overlapping-serde-ranges") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![ + first.clone(), + SccmEvidenceRef { + entry_id: "entry-b".into(), + line_start: Some(7), + line_end: Some(9), + ..first + }, + ]) + .build() + .unwrap(); + + let mut json = serde_json::to_value(&finding).unwrap(); + json["evidence"][1]["lineStart"] = serde_json::json!(6); + let error = serde_json::from_value::(json) + .unwrap_err() + .to_string(); + assert!(error.contains("OverlappingEvidenceReference"), "{error}"); + + finding.evidence[1].line_start = Some(6); + assert_eq!( + finding.validate().unwrap_err(), + SccmFindingValidationError::OverlappingEvidenceReference + ); + let error = serde_json::to_string(&finding).unwrap_err().to_string(); + assert!(error.contains("OverlappingEvidenceReference"), "{error}"); +} + +#[test] +fn finding_accepts_disjoint_and_unbounded_evidence_ranges() { + let anchor = SccmEvidenceRef { + artifact_id: "artifact-a".into(), + entry_id: "entry-a".into(), + line_start: Some(4), + line_end: Some(6), + }; + // Overlap is a claim about physical extent within one artifact. Adjacent + // spans, other artifacts, and references that assert no extent at all are + // all citations nine lanes already emit, and must keep validating. + let cases = [ + ( + "adjacent-below", + SccmEvidenceRef { + entry_id: "entry-b".into(), + line_start: Some(1), + line_end: Some(3), + ..anchor.clone() + }, + ), + ( + "adjacent-above", + SccmEvidenceRef { + entry_id: "entry-b".into(), + line_start: Some(7), + line_end: Some(9), + ..anchor.clone() + }, + ), + ( + "same-span-other-artifact", + SccmEvidenceRef { + artifact_id: "artifact-b".into(), + entry_id: "entry-b".into(), + ..anchor.clone() + }, + ), + ( + "unbounded-second", + SccmEvidenceRef { + entry_id: "entry-b".into(), + line_start: None, + line_end: None, + ..anchor.clone() + }, + ), + ]; + + for (label, second) in cases { + let finding = SccmFindingBuilder::new(format!("disjoint-{label}")) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![anchor.clone(), second]) + .build() + .unwrap_or_else(|error| panic!("{label}: {error:?}")); + let json = serde_json::to_value(&finding).unwrap(); + assert_eq!( + serde_json::from_value::(json).unwrap(), + finding, + "{label}" + ); + } + + // Two references that both assert no extent stay indistinguishable by span + // and must not be treated as claiming the same lines. + let unbounded = SccmEvidenceRef { + artifact_id: "artifact-a".into(), + entry_id: "entry-a".into(), + line_start: None, + line_end: None, + }; + SccmFindingBuilder::new("disjoint-both-unbounded") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![ + unbounded.clone(), + SccmEvidenceRef { + entry_id: "entry-b".into(), + ..unbounded + }, + ]) + .build() + .expect("unbounded references assert no physical extent"); +} + +#[test] +fn finding_rejects_top_level_evidence_identity_whitespace_aliases() { + assert_evidence_alias_is_rejected(FindingEvidenceAliasSurface::TopLevel); +} + +#[test] +fn finding_rejects_terminal_evidence_identity_whitespace_aliases() { + assert_evidence_alias_is_rejected(FindingEvidenceAliasSurface::Terminal); +} + +#[test] +fn finding_rejects_correlation_key_evidence_identity_whitespace_aliases() { + assert_evidence_alias_is_rejected(FindingEvidenceAliasSurface::CorrelationKey); +} + +#[test] +fn finding_rejects_noncanonical_opaque_ids_across_public_boundaries() { + let mut mismatches = Vec::new(); + + if finding_with_evidence_alias(" finding-id ", None).err() + != Some(SccmFindingValidationError::MissingRequiredField) + { + mismatches.push("builder accepted a noncanonical finding ID"); + } + let mut finding_id = finding_with_evidence_alias("finding-id", None).unwrap(); + finding_id.finding_id = " finding-id ".into(); + if finding_id.validate().err() != Some(SccmFindingValidationError::MissingRequiredField) { + mismatches.push("direct validate accepted a noncanonical finding ID"); + } + if serde_json::to_value(&finding_id).is_ok() { + mismatches.push("serializer accepted a noncanonical finding ID"); + } + let mut finding_id_json = + serde_json::to_value(finding_with_evidence_alias("finding-id-json", None).unwrap()) + .unwrap(); + finding_id_json["findingId"] = serde_json::json!(" finding-id-json "); + if serde_json::from_value::(finding_id_json).is_ok() { + mismatches.push("deserializer accepted a noncanonical finding ID"); + } + + let gap_builder = SccmFindingBuilder::new("noncanonical-gap-id") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .coverage_gap(finding_client_gap( + " client-policy-agent ", + SccmCoverageState::AccessDenied, + )) + .build(); + if gap_builder.err() != Some(SccmFindingValidationError::InvalidCoverageGap) { + mismatches.push("builder accepted a noncanonical coverage-gap artifact ID"); + } + let mut gap = finding_with_gap_and_request("noncanonical-gap-direct"); + gap.coverage_gaps[0].artifact_id = " client-policy-agent ".into(); + if gap.validate().err() != Some(SccmFindingValidationError::InvalidCoverageGap) { + mismatches.push("direct validate accepted a noncanonical coverage-gap artifact ID"); + } + if serde_json::to_value(&gap).is_ok() { + mismatches.push("serializer accepted a noncanonical coverage-gap artifact ID"); + } + let mut gap_json = + serde_json::to_value(finding_with_gap_and_request("noncanonical-gap-json")).unwrap(); + gap_json["coverageGaps"][0]["artifactId"] = serde_json::json!(" client-policy-agent "); + if serde_json::from_value::(gap_json).is_ok() { + mismatches.push("deserializer accepted a noncanonical coverage-gap artifact ID"); + } + + let request_builder = SccmFindingBuilder::new("noncanonical-request-id") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request( + " policyAgent ", + SccmRole::Client, + " Confirm the bounded policy request outcome. ", + )) + .build(); + if request_builder.err() != Some(SccmFindingValidationError::UndeclaredArtifactRequest) { + mismatches.push("builder accepted a noncanonical request logical ID"); + } + let mut request = finding_with_gap_and_request("noncanonical-request-direct"); + request.next_artifacts[0].logical_id = " policyAgent ".into(); + request.next_artifacts[0].reason = " Confirm the bounded policy request outcome. ".into(); + if request.validate().err() != Some(SccmFindingValidationError::UndeclaredArtifactRequest) { + mismatches.push("direct validate did not reject a noncanonical request logical ID"); + } + if serde_json::to_value(&request).is_ok() { + mismatches.push("serializer accepted a noncanonical request logical ID"); + } + let mut request_json = + serde_json::to_value(finding_with_gap_and_request("noncanonical-request-json")).unwrap(); + request_json["nextArtifacts"][0]["logicalId"] = serde_json::json!(" policyAgent "); + request_json["nextArtifacts"][0]["reason"] = + serde_json::json!(" Confirm the bounded policy request outcome. "); + if serde_json::from_value::(request_json).is_ok() { + mismatches.push("deserializer accepted a noncanonical request logical ID"); + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +fn bounded_finding_with_id(finding_id: &str) -> Result { + SccmFindingBuilder::new(finding_id) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build() +} + +#[test] +fn finding_rejects_overlong_opaque_ids_across_public_boundaries() { + let mut mismatches: Vec = Vec::new(); + let overlong_id = "a".repeat(257); + let bounded_id = "a".repeat(256); + + if bounded_finding_with_id(&overlong_id).err() + != Some(SccmFindingValidationError::MissingRequiredField) + { + mismatches.push("builder accepted an overlong finding ID".into()); + } + + for (label, artifact_id, entry_id) in [ + ("artifact ID", overlong_id.as_str(), "entry-a"), + ("entry ID", "artifact-a", overlong_id.as_str()), + ] { + let result = SccmFindingBuilder::new("overlong-evidence-id") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref(artifact_id, entry_id)]) + .build(); + if result.err() != Some(SccmFindingValidationError::InvalidEvidenceReference) { + mismatches.push(format!("builder accepted an overlong evidence {label}")); + } + } + + let gap_result = SccmFindingBuilder::new("overlong-gap-artifact-id") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .coverage_gap(finding_client_gap( + &overlong_id, + SccmCoverageState::AccessDenied, + )) + .build(); + if gap_result.err() != Some(SccmFindingValidationError::InvalidCoverageGap) { + mismatches.push("builder accepted an overlong coverage-gap artifact ID".into()); + } + + let key_evidence = finding_evidence_ref("artifact-a", "entry-a"); + let key_result = SccmFindingBuilder::new("overlong-key-profile-id") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![key_evidence.clone()]) + .correlation_keys(vec![finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some(overlong_id.as_str()), + key_evidence, + )]) + .build(); + if key_result.err() != Some(SccmFindingValidationError::InvalidCorrelationKey) { + mismatches.push("builder accepted an overlong correlation-key profile ID".into()); + } + + let mut direct = bounded_finding_with_id("overlong-direct").unwrap(); + direct.finding_id = overlong_id.clone(); + if direct.validate().err() != Some(SccmFindingValidationError::MissingRequiredField) { + mismatches.push("direct validate accepted an overlong finding ID".into()); + } + if serde_json::to_value(&direct).is_ok() { + mismatches.push("serializer accepted an overlong finding ID".into()); + } + + let canonical_json = + serde_json::to_value(bounded_finding_with_id("overlong-json").unwrap()).unwrap(); + let mut finding_id_json = canonical_json.clone(); + finding_id_json["findingId"] = serde_json::json!(overlong_id); + if serde_json::from_value::(finding_id_json).is_ok() { + mismatches.push("deserializer accepted an overlong finding ID".into()); + } + for field in ["artifactId", "entryId"] { + let mut evidence_json = canonical_json.clone(); + evidence_json["evidence"][0][field] = serde_json::json!(overlong_id); + if serde_json::from_value::(evidence_json).is_ok() { + mismatches.push(format!( + "deserializer accepted an overlong evidence {field}" + )); + } + } + + let bounded = SccmFindingBuilder::new(bounded_id.as_str()) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref(&bounded_id, &bounded_id)]) + .build(); + match bounded { + Ok(bounded) => { + let json = serde_json::to_value(&bounded).unwrap(); + if serde_json::from_value::(json).ok().as_ref() != Some(&bounded) { + mismatches.push("bound-length opaque IDs did not round trip".into()); + } + } + Err(error) => { + mismatches.push(format!( + "builder rejected bound-length opaque IDs: {error:?}" + )); + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn finding_rejects_overlong_display_text_across_public_boundaries() { + let mut mismatches: Vec = Vec::new(); + let overlong_title = "t".repeat(513); + let overlong_summary = "s".repeat(2049); + + for (label, title, summary) in [ + ("title", overlong_title.as_str(), "bounded summary"), + ("summary", "bounded title", overlong_summary.as_str()), + ] { + let result = SccmFindingBuilder::new("overlong-display-text") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .title(title) + .summary(summary) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build(); + if result.err() != Some(SccmFindingValidationError::MissingRequiredField) { + mismatches.push(format!("builder accepted an overlong {label}")); + } + } + + let mut direct = bounded_finding_with_id("overlong-title-direct").unwrap(); + direct.title = overlong_title.clone(); + if direct.validate().err() != Some(SccmFindingValidationError::MissingRequiredField) { + mismatches.push("direct validate accepted an overlong title".into()); + } + if serde_json::to_value(&direct).is_ok() { + mismatches.push("serializer accepted an overlong title".into()); + } + let mut direct = bounded_finding_with_id("overlong-summary-direct").unwrap(); + direct.summary = overlong_summary.clone(); + if direct.validate().err() != Some(SccmFindingValidationError::MissingRequiredField) { + mismatches.push("direct validate accepted an overlong summary".into()); + } + if serde_json::to_value(&direct).is_ok() { + mismatches.push("serializer accepted an overlong summary".into()); + } + + let canonical_json = + serde_json::to_value(bounded_finding_with_id("overlong-text-json").unwrap()).unwrap(); + for (field, value) in [("title", &overlong_title), ("summary", &overlong_summary)] { + let mut json = canonical_json.clone(); + json[field] = serde_json::json!(value); + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!("deserializer accepted an overlong {field}")); + } + } + + let bounded = SccmFindingBuilder::new("bound-length-display-text") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .title("t".repeat(512)) + .summary("s".repeat(2048)) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build(); + match bounded { + Ok(bounded) => { + let json = serde_json::to_value(&bounded).unwrap(); + if serde_json::from_value::(json).ok().as_ref() != Some(&bounded) { + mismatches.push("bound-length display text did not round trip".into()); + } + } + Err(error) => { + mismatches.push(format!( + "builder rejected bound-length display text: {error:?}" + )); + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn finding_rejects_display_text_whose_stored_form_exceeds_the_bound() { + let mut accepted = Vec::new(); + + for (label, title, summary) in [ + ( + "title", + format!("{}bounded title", " ".repeat(512)), + "bounded summary".to_owned(), + ), + ( + "summary", + "bounded title".to_owned(), + format!("{}bounded summary", " ".repeat(2048)), + ), + ] { + let builder = SccmFindingBuilder::new("padded-display-text") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .title(&title) + .summary(&summary) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build(); + if builder.err() != Some(SccmFindingValidationError::MissingRequiredField) { + accepted.push(format!("builder accepted padded {label}")); + } + + let mut direct = bounded_finding_with_id("padded-display-direct").unwrap(); + direct.title = title.clone(); + direct.summary = summary.clone(); + if direct.validate().err() != Some(SccmFindingValidationError::MissingRequiredField) { + accepted.push(format!("direct validation accepted padded {label}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serialization accepted padded {label}")); + } + + let mut json = + serde_json::to_value(bounded_finding_with_id("padded-display-json").unwrap()).unwrap(); + json["title"] = serde_json::json!(title); + json["summary"] = serde_json::json!(summary); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserialization accepted padded {label}")); + } + } + + assert!( + accepted.is_empty(), + "accepted display text with an oversized stored form: {accepted:#?}" + ); +} + +fn finding_with_ci_key_value( + finding_id: &str, + digits: &str, +) -> Result { + let key_evidence = finding_evidence_ref("artifact-a", "entry-a"); + SccmFindingBuilder::new(finding_id) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![key_evidence.clone()]) + .correlation_keys(vec![finding_key( + SccmCorrelationKeyKind::CiId, + digits, + digits, + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + key_evidence, + )]) + .build() +} + +#[test] +fn finding_rejects_overlong_correlation_key_values() { + let mut mismatches: Vec = Vec::new(); + let overlong_digits = "1".repeat(257); + let bounded_digits = "1".repeat(256); + + if finding_with_ci_key_value("overlong-key-value", &overlong_digits).err() + != Some(SccmFindingValidationError::InvalidCorrelationKey) + { + mismatches.push("builder accepted an overlong correlation-key value".into()); + } + + for field in ["raw", "normalized"] { + let mut direct = finding_with_ci_key_value("overlong-key-value-direct", "71").unwrap(); + match field { + "raw" => direct.correlation_keys[0].raw = overlong_digits.clone(), + "normalized" => direct.correlation_keys[0].normalized = overlong_digits.clone(), + _ => unreachable!(), + } + if direct.validate().err() != Some(SccmFindingValidationError::InvalidCorrelationKey) { + mismatches.push(format!( + "direct validate accepted an overlong {field} value" + )); + } + if serde_json::to_value(&direct).is_ok() { + mismatches.push(format!("serializer accepted an overlong {field} value")); + } + + let mut json = serde_json::to_value( + finding_with_ci_key_value("overlong-key-value-json", "71").unwrap(), + ) + .unwrap(); + json["correlationKeys"][0][field] = serde_json::json!(overlong_digits); + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!("deserializer accepted an overlong {field} value")); + } + } + + match finding_with_ci_key_value("bound-length-key-value", &bounded_digits) { + Ok(bounded) => { + let json = serde_json::to_value(&bounded).unwrap(); + if serde_json::from_value::(json).ok().as_ref() != Some(&bounded) { + mismatches.push("bound-length correlation-key value did not round trip".into()); + } + } + Err(error) => { + mismatches.push(format!( + "builder rejected a bound-length correlation-key value: {error:?}" + )); + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn coverage_gap_deserializes_through_the_same_wire_contract_as_a_finding() { + let mut mismatches: Vec = Vec::new(); + + let gap = finding_client_gap("client-policy-agent", SccmCoverageState::AccessDenied); + let canonical = serde_json::to_value(&gap).unwrap(); + if serde_json::from_value::(canonical.clone()) + .ok() + .as_ref() + != Some(&gap) + { + mismatches.push("a canonical coverage gap did not round trip".into()); + } + + let mut unknown_field = canonical.clone(); + unknown_field["unexpectedField"] = serde_json::json!("surplus"); + if serde_json::from_value::(unknown_field).is_ok() { + mismatches.push("coverage gap deserializer accepted an unknown field".into()); + } + + let mut captured = canonical.clone(); + captured["coverage"] = serde_json::json!("captured"); + if serde_json::from_value::(captured).is_ok() { + mismatches.push("coverage gap deserializer accepted captured coverage".into()); + } + + for (label, artifact_id) in [ + ("an empty artifact ID", String::new()), + ( + "an untrimmed artifact ID", + " client-policy-agent ".to_owned(), + ), + ("an overlong artifact ID", "a".repeat(257)), + ] { + let mut json = canonical.clone(); + json["artifactId"] = serde_json::json!(artifact_id); + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!("coverage gap deserializer accepted {label}")); + } + } + + let mut untrimmed_role = canonical.clone(); + untrimmed_role["role"] = serde_json::json!(" client "); + if serde_json::from_value::(untrimmed_role).is_ok() { + mismatches.push("coverage gap deserializer accepted a nested untrimmed role".into()); + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn coverage_gap_serialization_enforces_the_full_standalone_contract() { + let mut empty_artifact = + finding_client_gap("client-policy-agent", SccmCoverageState::AccessDenied); + empty_artifact.artifact_id.clear(); + let mut overlong_artifact = + finding_client_gap("client-policy-agent", SccmCoverageState::AccessDenied); + overlong_artifact.artifact_id = "a".repeat(257); + let captured = finding_client_gap("client-policy-agent", SccmCoverageState::Captured); + let mut mismatches = Vec::new(); + + for (label, gap) in [ + ("an empty artifact ID", empty_artifact), + ("an overlong artifact ID", overlong_artifact), + ("captured coverage", captured), + ] { + match serde_json::to_value(gap) { + Ok(_) => mismatches.push(format!("serializer accepted {label}")), + Err(error) if !error.to_string().contains("InvalidCoverageGap") => { + mismatches.push(format!( + "serializer rejected {label} with the wrong contract error: {error}" + )); + } + Err(_) => {} + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn artifact_request_deserializes_through_the_same_wire_contract_as_a_finding() { + let mut mismatches: Vec = Vec::new(); + + let request = finding_request( + "policyAgent", + SccmRole::Client, + "Confirm the bounded policy request outcome.", + ); + let canonical = serde_json::to_value(&request).unwrap(); + if serde_json::from_value::(canonical.clone()) + .ok() + .as_ref() + != Some(&request) + { + mismatches.push("a canonical artifact request did not round trip".into()); + } + + let mut unknown_field = canonical.clone(); + unknown_field["unexpectedField"] = serde_json::json!("surplus"); + if serde_json::from_value::(unknown_field).is_ok() { + mismatches.push("artifact request deserializer accepted an unknown field".into()); + } + + for (label, logical_id) in [ + ("an empty logical ID", String::new()), + ("an untrimmed logical ID", " policyAgent ".to_owned()), + ("an overlong logical ID", "a".repeat(257)), + ( + "an undeclared logical ID", + "not-a-declared-source".to_owned(), + ), + ] { + let mut json = canonical.clone(); + json["logicalId"] = serde_json::json!(logical_id); + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!("artifact request deserializer accepted {label}")); + } + } + + for (label, reason) in [ + ("an empty reason", String::new()), + ( + "a rooted-path reason", + ROOTED_ARTIFACT_REQUEST_REASONS[1].to_owned(), + ), + ( + "an overlong reason", + "a".repeat(MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS + 1), + ), + ] { + let mut json = canonical.clone(); + json["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!("artifact request deserializer accepted {label}")); + } + } + + for (label, role) in [ + ("a nested untrimmed role", " client "), + ("a nested mismatched role", "distributionPoint"), + ] { + let mut json = canonical.clone(); + json["role"] = serde_json::json!(role); + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!("artifact request deserializer accepted {label}")); + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn artifact_request_serialization_enforces_the_full_standalone_contract() { + let cases = [ + ( + "an undeclared logical ID", + finding_request( + "not-a-declared-source", + SccmRole::Client, + "Confirm the not-a-declared-source request outcome.", + ), + "UndeclaredArtifactRequest", + ), + ( + "a role mismatch", + finding_request( + "policyAgent", + SccmRole::DistributionPoint, + "Confirm the bounded policy request outcome.", + ), + "ArtifactRequestRoleMismatch", + ), + ( + "a rooted-path reason", + finding_request( + "policyAgent", + SccmRole::Client, + ROOTED_ARTIFACT_REQUEST_REASONS[1], + ), + "InvalidArtifactRequestReason", + ), + ( + "an out-of-scope reason", + finding_request( + "policyAgent", + SccmRole::Client, + "Collect the complete CAS.log file.", + ), + "InvalidArtifactRequestReason", + ), + ]; + let mut mismatches = Vec::new(); + + for (label, request, expected_error) in cases { + match serde_json::to_value(request) { + Ok(_) => mismatches.push(format!("serializer accepted {label}")), + Err(error) if !error.to_string().contains(expected_error) => mismatches.push(format!( + "serializer rejected {label} with the wrong contract error: {error}" + )), + Err(_) => {} + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn artifact_request_deserialization_returns_a_canonical_reason() { + let mut json = serde_json::to_value(finding_request( + "policyAgent", + SccmRole::Client, + "Confirm the bounded policy request outcome.", + )) + .unwrap(); + json["reason"] = serde_json::json!(" Confirm the bounded policy request outcome. "); + + let request = serde_json::from_value::(json).unwrap(); + + assert_eq!( + request.reason, + "Confirm the bounded policy request outcome." + ); + + let serialized = serde_json::to_value(finding_request( + "policyAgent", + SccmRole::Client, + " Confirm the bounded policy request outcome. ", + )) + .unwrap(); + assert_eq!( + serialized["reason"], + "Confirm the bounded policy request outcome." + ); +} + +#[test] +fn artifact_request_rejects_padding_that_exceeds_the_reason_bound() { + let reason = format!( + "{}Confirm the bounded policy request outcome.", + " ".repeat(MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS) + ); + let mut accepted = Vec::new(); + + let builder = SccmFindingBuilder::new("padded-request-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, &reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push("builder".to_owned()); + } + + let mut direct = finding_with_gap_and_request("padded-request-direct"); + direct.next_artifacts[0].reason = reason.clone(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push("direct validation".to_owned()); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push("serialization".to_owned()); + } + + let mut finding_json = + serde_json::to_value(finding_with_gap_and_request("padded-request-json")).unwrap(); + finding_json["nextArtifacts"][0]["reason"] = serde_json::json!(&reason); + if serde_json::from_value::(finding_json).is_ok() { + accepted.push("finding deserialization".to_owned()); + } + + let mut request_json = serde_json::to_value(finding_request( + "policyAgent", + SccmRole::Client, + "Confirm the bounded policy request outcome.", + )) + .unwrap(); + request_json["reason"] = serde_json::json!(reason); + if serde_json::from_value::(request_json).is_ok() { + accepted.push("standalone request deserialization".to_owned()); + } + if serde_json::to_value(finding_request("policyAgent", SccmRole::Client, &reason)).is_ok() { + accepted.push("standalone request serialization".to_owned()); + } + + assert!( + accepted.is_empty(), + "accepted an artifact request whose stored reason exceeds the bound: {accepted:#?}" + ); +} + +#[test] +fn evidence_ref_deserializes_through_the_same_wire_contract_as_a_finding() { + let mut mismatches: Vec = Vec::new(); + + let reference = finding_evidence_ref("artifact-a", "entry-a"); + let canonical = serde_json::to_value(&reference).unwrap(); + if serde_json::from_value::(canonical.clone()) + .ok() + .as_ref() + != Some(&reference) + { + mismatches.push("a canonical evidence ref did not round trip".into()); + } + + let mut unknown_field = canonical.clone(); + unknown_field["unexpectedField"] = serde_json::json!("surplus"); + if serde_json::from_value::(unknown_field).is_ok() { + mismatches.push("evidence ref deserializer accepted an unknown field".into()); + } + + for (label, json) in noncanonical_evidence_ref_payloads() { + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!("evidence ref deserializer accepted {label}")); + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn evidence_ref_serialization_enforces_the_full_standalone_contract() { + let canonical = finding_evidence_ref("artifact-a", "entry-a"); + let mut empty_artifact = canonical.clone(); + empty_artifact.artifact_id.clear(); + let mut overlong_entry = canonical.clone(); + overlong_entry.entry_id = "e".repeat(257); + let mut half_set_range = canonical.clone(); + half_set_range.line_end = None; + let mut zero_start = canonical.clone(); + zero_start.line_start = Some(0); + let mut inverted_range = canonical; + inverted_range.line_start = Some(9); + inverted_range.line_end = Some(2); + + let mut mismatches = Vec::new(); + for (label, reference) in [ + ("an empty artifact ID", empty_artifact), + ("an overlong entry ID", overlong_entry), + ("a half-set line range", half_set_range), + ("a zero line start", zero_start), + ("an inverted line range", inverted_range), + ] { + match serde_json::to_value(reference) { + Ok(_) => mismatches.push(format!("serializer accepted {label}")), + Err(error) if !error.to_string().contains("InvalidEvidenceReference") => { + mismatches.push(format!( + "serializer rejected {label} with the wrong contract error: {error}" + )); + } + Err(_) => {} + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn terminal_evidence_deserializes_through_the_same_wire_contract_as_a_finding() { + let mut mismatches: Vec = Vec::new(); + + let terminal = + SccmTerminalEvidence::observed_failure(finding_evidence_ref("artifact-a", "entry-a")); + let canonical = serde_json::to_value(&terminal).unwrap(); + if serde_json::from_value::(canonical.clone()) + .ok() + .as_ref() + != Some(&terminal) + { + mismatches.push("a canonical terminal evidence did not round trip".into()); + } + + let mut unknown_field = canonical.clone(); + unknown_field["unexpectedField"] = serde_json::json!("surplus"); + if serde_json::from_value::(unknown_field).is_ok() { + mismatches.push("terminal evidence deserializer accepted an unknown field".into()); + } + + let mut nested_unknown = canonical.clone(); + nested_unknown["reference"]["unexpectedField"] = serde_json::json!("surplus"); + if serde_json::from_value::(nested_unknown).is_ok() { + mismatches.push("terminal evidence deserializer accepted a nested unknown field".into()); + } + + for (label, reference) in noncanonical_evidence_ref_payloads() { + let mut json = canonical.clone(); + json["reference"] = reference; + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!( + "terminal evidence deserializer accepted nested {label}" + )); + } + } + + let mut non_terminal_kind = canonical.clone(); + non_terminal_kind["kind"] = serde_json::json!("observedRecovery"); + if serde_json::from_value::(non_terminal_kind).is_ok() { + mismatches.push("terminal evidence deserializer accepted a non-failure kind".into()); + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn terminal_evidence_serialization_enforces_the_full_standalone_contract() { + let mut invalid_reference = + SccmTerminalEvidence::observed_failure(finding_evidence_ref("artifact-a", "entry-a")); + invalid_reference.reference.artifact_id.clear(); + let non_terminal_kind = SccmTerminalEvidence { + reference: finding_evidence_ref("artifact-a", "entry-a"), + kind: SccmTerminalEvidenceKind::Unknown("observedRecovery".to_owned()), + }; + let mut mismatches = Vec::new(); + + for (label, terminal, expected_error) in [ + ( + "an invalid nested reference", + invalid_reference, + "InvalidEvidenceReference", + ), + ( + "a non-failure terminal kind", + non_terminal_kind, + "InvalidTerminalEvidence", + ), + ] { + match serde_json::to_value(terminal) { + Ok(_) => mismatches.push(format!("serializer accepted {label}")), + Err(error) if !error.to_string().contains(expected_error) => mismatches.push(format!( + "serializer rejected {label} with the wrong contract error: {error}" + )), + Err(_) => {} + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn correlation_key_deserializes_through_the_same_wire_contract_as_a_finding() { + let mut mismatches: Vec = Vec::new(); + + let key = finding_key( + SccmCorrelationKeyKind::CiId, + "71", + "71", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + finding_evidence_ref("artifact-a", "entry-a"), + ); + let canonical = serde_json::to_value(&key).unwrap(); + if serde_json::from_value::(canonical.clone()) + .ok() + .as_ref() + != Some(&key) + { + mismatches.push("a canonical correlation key did not round trip".into()); + } + + let mut unknown_field = canonical.clone(); + unknown_field["unexpectedField"] = serde_json::json!("surplus"); + if serde_json::from_value::(unknown_field).is_ok() { + mismatches.push("correlation key deserializer accepted an unknown field".into()); + } + + // The trust-signal bypass. REGISTERED_STABLE_CORRELATION_PROFILE_IDS is + // deliberately empty, so validate_correlation_key_evidence holds every key + // at Low; standalone deserialization must not let a payload forge a + // stronger confidence than any registered profile can authorize. + for forged in ["exact", "strong"] { + let mut json = canonical.clone(); + json["confidence"] = serde_json::json!(forged); + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!( + "correlation key deserializer accepted forged {forged} confidence" + )); + } + } + + for (label, field, value) in [ + ("a 5000-char raw value", "raw", "1".repeat(5000)), + ( + "a 5000-char normalized value", + "normalized", + "1".repeat(5000), + ), + ("an empty raw value", "raw", String::new()), + ] { + let mut json = canonical.clone(); + json[field] = serde_json::json!(value); + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!("correlation key deserializer accepted {label}")); + } + } + + let mut incoherent_span = canonical.clone(); + incoherent_span["start"] = serde_json::json!(99999); + incoherent_span["end"] = serde_json::json!(1); + if serde_json::from_value::(incoherent_span).is_ok() { + mismatches.push("correlation key deserializer accepted an incoherent span".into()); + } + + let mut overlong_profile = canonical.clone(); + overlong_profile["extractionProfileId"] = serde_json::json!("p".repeat(257)); + if serde_json::from_value::(overlong_profile).is_ok() { + mismatches.push("correlation key deserializer accepted an overlong profile ID".into()); + } + + let mut no_evidence = canonical.clone(); + no_evidence["evidence"] = serde_json::Value::Null; + if serde_json::from_value::(no_evidence).is_ok() { + mismatches.push("correlation key deserializer accepted a key with no evidence".into()); + } + + // The key's own evidence reference is the citation set the contract checks + // it against, so `evidence.contains(reference)` is self-satisfying here. It + // proves nothing about the reference itself, which must still clear the + // same bar a standalone SccmEvidenceRef clears. + let mut nested_unknown = canonical.clone(); + nested_unknown["evidence"]["unexpectedField"] = serde_json::json!("surplus"); + if serde_json::from_value::(nested_unknown).is_ok() { + mismatches.push("correlation key deserializer accepted a nested unknown field".into()); + } + + for (label, evidence) in noncanonical_evidence_ref_payloads() { + let mut json = canonical.clone(); + json["evidence"] = evidence; + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!( + "correlation key deserializer accepted nested {label}" + )); + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn correlation_key_serialization_enforces_the_full_standalone_contract() { + let canonical = finding_key( + SccmCorrelationKeyKind::CiId, + "71", + "71", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + finding_evidence_ref("artifact-a", "entry-a"), + ); + let mut overlong_raw = canonical.clone(); + overlong_raw.raw = "1".repeat(257); + let mut overlong_normalized = canonical.clone(); + overlong_normalized.normalized = "1".repeat(257); + let mut forged_confidence = canonical.clone(); + forged_confidence.confidence = SccmKeyConfidence::Strong; + let mut invalid_evidence = canonical.clone(); + invalid_evidence + .evidence + .as_mut() + .unwrap() + .artifact_id + .clear(); + let mut missing_evidence = canonical.clone(); + missing_evidence.evidence = None; + let mut incoherent_span = canonical; + incoherent_span.start = Some(8); + incoherent_span.end = Some(9); + + let mut mismatches = Vec::new(); + for (label, key, expected_error) in [ + ( + "an overlong raw value", + overlong_raw, + "InvalidCorrelationKey", + ), + ( + "an overlong normalized value", + overlong_normalized, + "InvalidCorrelationKey", + ), + ( + "forged strong confidence", + forged_confidence, + "InvalidCorrelationKey", + ), + ( + "an invalid evidence reference", + invalid_evidence, + "InvalidEvidenceReference", + ), + ( + "a missing evidence reference", + missing_evidence, + "CorrelationKeyMissingEvidence", + ), + ( + "an incoherent UTF-16 span", + incoherent_span, + "InvalidCorrelationKey", + ), + ] { + match serde_json::to_value(key) { + Ok(_) => mismatches.push(format!("serializer accepted {label}")), + Err(error) if !error.to_string().contains(expected_error) => mismatches.push(format!( + "serializer rejected {label} with the wrong contract error: {error}" + )), + Err(_) => {} + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn key_extraction_result_deserializes_keys_through_the_same_wire_contract() { + let mut mismatches: Vec = Vec::new(); + + let result = SccmKeyExtractionResult { + profile_id: "sccm-keys-experimental-v1".into(), + keys: vec![finding_key( + SccmCorrelationKeyKind::CiId, + "71", + "71", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + finding_evidence_ref("artifact-a", "entry-a"), + )], + gaps: Vec::new(), + }; + let canonical = serde_json::to_value(&result).unwrap(); + if serde_json::from_value::(canonical.clone()) + .ok() + .as_ref() + != Some(&result) + { + mismatches.push("a canonical key extraction result did not round trip".into()); + } + + for (label, evidence) in noncanonical_evidence_ref_payloads() { + let mut json = canonical.clone(); + json["keys"][0]["evidence"] = evidence; + if serde_json::from_value::(json).is_ok() { + mismatches.push(format!( + "key extraction result deserializer accepted nested {label}" + )); + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn finding_rejects_whitespace_wrapped_declared_phase_shadow() { + let result = SccmFindingBuilder::new("wrapped-phase-shadow") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Unknown(" policy ".into())) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::MissingRequiredField + ); + + let mut mutated = SccmFindingBuilder::new("direct-wrapped-phase-shadow") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build() + .unwrap(); + mutated.phase = SccmPhase::Unknown(" policy ".into()); + assert_eq!( + mutated.validate().unwrap_err(), + SccmFindingValidationError::MissingRequiredField + ); +} + +#[test] +fn finding_deserialization_rejects_whitespace_wrapped_declared_phase_shadow() { + let finding = SccmFindingBuilder::new("deserialized-wrapped-phase-shadow") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build() + .unwrap(); + let mut json = serde_json::to_value(finding).unwrap(); + json["phase"] = serde_json::json!(" policy "); + + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn finding_serialization_rejects_whitespace_wrapped_declared_phase_shadow() { + let mut finding = SccmFindingBuilder::new("serialized-wrapped-phase-shadow") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build() + .unwrap(); + finding.phase = SccmPhase::Unknown(" policy ".into()); + + assert!(serde_json::to_value(finding).is_err()); +} + +#[test] +fn finding_rejects_noncanonical_future_phase_whitespace() { + let result = SccmFindingBuilder::new("noncanonical-future-phase") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Unknown(" futurePhase ".into())) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::MissingRequiredField + ); + + let mut mutated = SccmFindingBuilder::new("direct-noncanonical-future-phase") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Unknown("futurePhase".into())) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build() + .unwrap(); + mutated.phase = SccmPhase::Unknown(" futurePhase ".into()); + assert_eq!( + mutated.validate().unwrap_err(), + SccmFindingValidationError::MissingRequiredField + ); + assert!(serde_json::to_value(&mutated).is_err()); + + let mut json = serde_json::to_value( + SccmFindingBuilder::new("deserialized-noncanonical-future-phase") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Unknown("futurePhase".into())) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build() + .unwrap(), + ) + .unwrap(); + json["phase"] = serde_json::json!(" futurePhase "); + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn finding_phase_unknown_values_require_canonical_standalone_serde() { + for value in ["", " ", " policy ", " futurePhase "] { + assert!( + serde_json::to_string(&SccmPhase::Unknown(value.into())).is_err(), + "serialized {value:?}" + ); + let wire = serde_json::to_string(value).unwrap(); + assert!( + serde_json::from_str::(&wire).is_err(), + "deserialized {value:?}" + ); + } + for value in ["policy", "content", "enforcement"] { + assert!( + serde_json::to_string(&SccmPhase::Unknown(value.into())).is_err(), + "shadowed {value:?}" + ); + } + + let phase = SccmPhase::Unknown("futurePhase".into()); + let wire = serde_json::to_string(&phase).unwrap(); + assert_eq!(serde_json::from_str::(&wire).unwrap(), phase); + + let finding = SccmFindingBuilder::new("canonical-future-phase") + .class(SccmFindingClass::Symptom) + .phase(phase) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build() + .unwrap(); + assert_eq!( + serde_json::to_value(finding).unwrap()["phase"], + "futurePhase" + ); +} + +#[test] +fn finding_terminal_evidence_kind_unknown_values_require_canonical_standalone_serde() { + for value in ["", " ", " futureTerminalKind "] { + assert!( + serde_json::to_string(&SccmTerminalEvidenceKind::Unknown(value.into())).is_err(), + "serialized {value:?}" + ); + let wire = serde_json::to_string(value).unwrap(); + assert!( + serde_json::from_str::(&wire).is_err(), + "deserialized {value:?}" + ); + } + assert!( + serde_json::to_string(&SccmTerminalEvidenceKind::Unknown("observedFailure".into())) + .is_err() + ); + + let future = SccmTerminalEvidenceKind::Unknown("futureTerminalKind".into()); + let wire = serde_json::to_string(&future).unwrap(); + assert_eq!( + serde_json::from_str::(&wire).unwrap(), + future + ); +} + +#[test] +fn finding_role_unknown_values_cannot_shadow_declared_roles() { + for value in ["", " ", " futureRole "] { + assert!( + serde_json::to_string(&SccmRole::Unknown(value.into())).is_err(), + "{value:?}" + ); + let wire = serde_json::to_string(value).unwrap(); + assert!( + serde_json::from_str::(&wire).is_err(), + "{value:?}" + ); + } + + for value in [ + "client", + "siteServer", + "managementPoint", + "distributionPoint", + "softwareUpdatePoint", + "wsUs", + "provider", + "adminService", + ] { + assert!( + serde_json::to_string(&SccmRole::Unknown(value.into())).is_err(), + "{value:?}" + ); + } + + let future = SccmRole::Unknown("futureRole".into()); + let wire = serde_json::to_string(&future).unwrap(); + assert_eq!(serde_json::from_str::(&wire).unwrap(), future); +} + +#[test] +fn finding_validates_finding_gap_and_request_roles_before_other_rules() { + let top_level = SccmFindingBuilder::new("invalid-top-level-role") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Unknown("client".into())) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .build(); + let gap = SccmFindingBuilder::new("invalid-gap-role") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .coverage_gap(SccmFindingCoverageGap { + artifact_id: "client-policy-agent".into(), + role: SccmRole::Unknown("client".into()), + coverage: SccmCoverageState::AccessDenied, + }) + .build(); + let request = SccmFindingBuilder::new("invalid-request-role") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request( + "policyAgent", + SccmRole::Unknown("client".into()), + "Confirm the bounded policy request outcome.", + )) + .build(); + + for (label, result) in [ + ("finding", top_level), + ("coverage-gap", gap), + ("artifact-request", request), + ] { + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidRole, + "{label}" + ); + } +} + +#[test] +fn finding_deserialization_validates_finding_gap_and_request_roles() { + let json = + serde_json::to_value(finding_with_gap_and_request("invalid-deserialized-role")).unwrap(); + let mut cases = Vec::new(); + + let mut top_level = json.clone(); + top_level["role"] = serde_json::json!(" client "); + cases.push(("finding", top_level)); + + let mut gap = json.clone(); + gap["coverageGaps"][0]["role"] = serde_json::json!(" client "); + cases.push(("coverage-gap", gap)); + + let mut request = json; + request["nextArtifacts"][0]["role"] = serde_json::json!(" client "); + cases.push(("artifact-request", request)); + + for (label, case) in cases { + let error = serde_json::from_value::(case) + .unwrap_err() + .to_string(); + assert!(error.contains("InvalidRole"), "{label}: {error}"); + } +} + +#[test] +fn finding_serialization_validates_finding_gap_and_request_roles() { + let finding = finding_with_gap_and_request("invalid-serialized-role"); + let mut cases = Vec::new(); + + let mut top_level = finding.clone(); + top_level.role = SccmRole::Unknown("client".into()); + cases.push(("finding", top_level)); + + let mut gap = finding.clone(); + gap.coverage_gaps[0].role = SccmRole::Unknown("client".into()); + cases.push(("coverage-gap", gap)); + + let mut request = finding; + request.next_artifacts[0].role = SccmRole::Unknown("client".into()); + cases.push(("artifact-request", request)); + + for (label, case) in cases { + let error = serde_json::to_string(&case).unwrap_err().to_string(); + assert!(error.contains("InvalidRole"), "{label}: {error}"); + } +} + +#[test] +fn finding_artifact_requests_reject_structurally_unbounded_reasons() { + let reasons = [ + "Collect every file on the system.", + "Scan the full disk for related evidence.", + "Collect the complete C: drive.", + "Walk all directories under C:.", + "Collect drive-wide logs.", + "Collect drive wide logs.", + "Collect drivewide logs.", + "Collect sitewide logs.", + "Recursively collect PolicyAgent.log.", + "Collect from the filesystem root.", + "Use a glob for matching log files.", + r"Collect C:\Windows\CCM\Logs\*.log.", + ]; + let mut accepted = Vec::new(); + + for reason in reasons + .into_iter() + .chain(ROOTED_ARTIFACT_REQUEST_REASONS) + .chain(COMPACT_UNBOUNDED_ARTIFACT_REQUEST_REASONS) + .chain(ORDER_INDEPENDENT_UNBOUNDED_ARTIFACT_REQUEST_REASONS) + { + let result = SccmFindingBuilder::new("unbounded-structural-request") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + + if result.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(reason); + } + } + + assert!( + accepted.is_empty(), + "accepted unbounded reasons: {accepted:#?}" + ); +} + +#[test] +fn finding_artifact_requests_accept_specific_bounded_reasons() { + let existing = [ + ("policyAgent", "Confirm the bounded policy request outcome."), + ( + "policyAgent", + "Collect the PolicyAgent record cited by assignment A.", + ), + ( + "policyAgent", + "Confirm the root cause recorded by PolicyAgent.", + ), + ( + "policyAgent", + "Confirm the disk status code recorded in PolicyAgent.log.", + ), + ("smsts", "Collect the disk imaging Task Sequence log."), + ( + "policyAgent", + "Collect Logs/PolicyAgent.log from the bounded bundle.", + ), + ( + "policyAgent", + r"Collect Logs\PolicyAgent.log from the bounded bundle.", + ), + ]; + let canonical = finding_with_gap_and_request("bounded-reason-parity"); + let mut rejected = Vec::new(); + + for (logical_id, reason) in existing + .into_iter() + .chain(BOUNDED_NARRATIVE_ARTIFACT_REQUESTS) + { + if SccmFindingBuilder::new("bounded-structural-request") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request(logical_id, SccmRole::Client, reason)) + .build() + .is_err() + { + rejected.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = finding_request(logical_id, SccmRole::Client, reason); + if direct.validate().is_err() { + rejected.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_err() { + rejected.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = + serde_json::to_value(finding_request(logical_id, SccmRole::Client, reason)).unwrap(); + if serde_json::from_value::(json).is_err() { + rejected.push(format!("deserializer: {reason}")); + } + } + + assert!( + rejected.is_empty(), + "rejected bounded reasons: {rejected:#?}" + ); +} + +#[test] +fn finding_artifact_request_bounds_apply_to_deserialization_and_serialization() { + let finding = finding_with_gap_and_request("request-boundary-parity"); + let mut accepted = Vec::new(); + for reason in ROOTED_ARTIFACT_REQUEST_REASONS + .into_iter() + .chain([ + "Collect every file on the system.", + "Scan the full disk for related evidence.", + ]) + .chain(COMPACT_UNBOUNDED_ARTIFACT_REQUEST_REASONS) + .chain(ORDER_INDEPENDENT_UNBOUNDED_ARTIFACT_REQUEST_REASONS) + { + let mut direct = finding.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + + let mut json = serde_json::to_value(&finding).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + + let mut mutated = finding.clone(); + mutated.next_artifacts[0].reason = reason.into(); + if serde_json::to_string(&mutated).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted unbounded request boundaries: {accepted:#?}" + ); +} + +#[test] +fn finding_review_unbounded_scope_matrix_fails_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-unbounded-scope-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_UNBOUNDED_ARTIFACT_REQUEST_REASONS { + let builder = SccmFindingBuilder::new("review-unbounded-scope-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted reviewed unbounded request boundaries: {accepted:#?}" + ); +} + +#[test] +fn finding_review_bounded_named_artifact_matrix_passes_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-bounded-scope-parity"); + let mut rejected = Vec::new(); + + for (logical_id, reason) in REVIEW_BOUNDED_NAMED_ARTIFACT_REQUESTS { + let builder = SccmFindingBuilder::new("review-bounded-scope-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request(logical_id, SccmRole::Client, reason)) + .build(); + if builder.is_err() { + rejected.push(format!("builder: {logical_id}: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = finding_request(logical_id, SccmRole::Client, reason); + if direct.validate().is_err() { + rejected.push(format!("direct validate: {logical_id}: {reason}")); + } + if serde_json::to_value(&direct).is_err() { + rejected.push(format!("serializer: {logical_id}: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = + serde_json::to_value(finding_request(logical_id, SccmRole::Client, reason)).unwrap(); + if serde_json::from_value::(json).is_err() { + rejected.push(format!("deserializer: {logical_id}: {reason}")); + } + } + + assert!( + rejected.is_empty(), + "rejected reviewed bounded request boundaries: {rejected:#?}" + ); +} + +#[test] +fn finding_review_expanded_unbounded_scope_fails_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-expanded-unbounded-scope-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_EXPANDED_UNBOUNDED_ARTIFACT_REQUEST_REASONS { + let builder = SccmFindingBuilder::new("review-expanded-unbounded-scope-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted expanded unbounded request boundaries: {accepted:#?}" + ); +} + +#[test] +fn finding_review_lookalike_identity_fails_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-lookalike-identity-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_LOOKALIKE_ARTIFACT_REQUEST_REASONS { + let builder = SccmFindingBuilder::new("review-lookalike-identity-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted lookalike artifact request boundaries: {accepted:#?}" + ); +} + +#[test] +fn finding_review_unqualified_collection_actions_fail_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-unqualified-action-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_UNQUALIFIED_COLLECTION_ACTION_REASONS { + let builder = SccmFindingBuilder::new("review-unqualified-action-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted unqualified collection action boundaries: {accepted:#?}" + ); +} + +#[test] +fn finding_review_coordinated_unqualified_actions_fail_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-coordinated-action-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_COORDINATED_UNQUALIFIED_ACTION_REASONS { + let builder = SccmFindingBuilder::new("review-coordinated-action-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted coordinated unqualified action boundaries: {accepted:#?}" + ); +} + +#[test] +fn finding_review_unbound_collection_targets_fail_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-unbound-target-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_UNBOUND_COLLECTION_TARGET_REASONS { + let builder = SccmFindingBuilder::new("review-unbound-target-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted unbound collection target boundaries: {accepted:#?}" + ); +} + +#[test] +fn finding_review_safe_collection_narratives_pass_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-safe-narrative-parity"); + let mut rejected = Vec::new(); + + for reason in REVIEW_SAFE_COLLECTION_NARRATIVE_REASONS { + let builder = SccmFindingBuilder::new("review-safe-narrative-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if let Err(error) = builder { + rejected.push(format!("builder ({error:?}): {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if let Err(error) = direct.validate() { + rejected.push(format!("direct validate ({error:?}): {reason}")); + } + if serde_json::to_value(&direct).is_err() { + rejected.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_err() { + rejected.push(format!("deserializer: {reason}")); + } + } + + assert!( + rejected.is_empty(), + "rejected safe collection narratives: {rejected:#?}" + ); +} + +#[test] +fn finding_review_non_authorizing_collection_language_fails_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-non-authorizing-language-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_NON_AUTHORIZING_COLLECTION_LANGUAGE_REASONS { + let builder = SccmFindingBuilder::new("review-non-authorizing-language-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted non-authorizing collection language: {accepted:#?}" + ); +} + +#[test] +fn finding_review_safe_strong_punctuation_narratives_pass_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-safe-strong-narrative-parity"); + let mut rejected = Vec::new(); + + for reason in REVIEW_SAFE_STRONG_PUNCTUATION_NARRATIVE_REASONS { + let builder = SccmFindingBuilder::new("review-safe-strong-narrative-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if let Err(error) = builder { + rejected.push(format!("builder ({error:?}): {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if let Err(error) = direct.validate() { + rejected.push(format!("direct validate ({error:?}): {reason}")); + } + if serde_json::to_value(&direct).is_err() { + rejected.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_err() { + rejected.push(format!("deserializer: {reason}")); + } + } + + assert!( + rejected.is_empty(), + "rejected safe strong-punctuation narratives: {rejected:#?}" + ); +} + +#[test] +fn finding_review_evidence_subject_cannot_authorize_passive_target_at_any_public_boundary() { + let canonical = finding_with_gap_and_request("review-evidence-passive-target-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_EVIDENCE_SUBJECT_UNBOUND_PASSIVE_REASONS { + let builder = SccmFindingBuilder::new("review-evidence-passive-target-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted evidence-subject passive targets: {accepted:#?}" + ); +} + +#[test] +fn finding_review_standalone_and_inflected_collection_requests_fail_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-standalone-inflected-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_STANDALONE_AND_INFLECTED_COLLECTION_REASONS { + let builder = SccmFindingBuilder::new("review-standalone-inflected-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted standalone or inflected collection requests: {accepted:#?}" + ); +} + +#[test] +fn finding_review_unrecognized_confirmation_requests_fail_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-confirmation-request-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_UNRECOGNIZED_CONFIRMATION_REQUEST_REASONS { + let builder = SccmFindingBuilder::new("review-confirmation-request-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted unrecognized confirmation requests: {accepted:#?}" + ); +} + +#[test] +fn finding_review_passive_unbounded_confirmation_requests_fail_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-passive-confirmation-parity"); + let mut accepted = Vec::new(); + + for reason in REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS { + let logical_id = if reason.contains("Smsts.log") { + "smsts" + } else { + "policyAgent" + }; + let builder = SccmFindingBuilder::new("review-passive-confirmation-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request(logical_id, SccmRole::Client, reason)) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) { + accepted.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = finding_request(logical_id, SccmRole::Client, reason); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidArtifactRequestReason) + { + accepted.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = serde_json::json!({ + "logicalId": logical_id, + "role": "client", + "reason": reason, + }); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {reason}")); + } + } + + assert!( + accepted.is_empty(), + "accepted passive unbounded confirmation requests: {accepted:#?}" + ); +} + +#[test] +fn finding_review_bounded_auxiliary_confirmations_pass_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-bounded-auxiliary-parity"); + let mut rejected = Vec::new(); + + for (logical_id, reason) in REVIEW_BOUNDED_AUXILIARY_CONFIRMATION_REASONS { + let builder = SccmFindingBuilder::new("review-bounded-auxiliary-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request(logical_id, SccmRole::Client, reason)) + .build(); + if builder.is_err() { + rejected.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = finding_request(logical_id, SccmRole::Client, reason); + if direct.validate().is_err() { + rejected.push(format!("direct validate: {reason}")); + } + if serde_json::to_value(&direct).is_err() { + rejected.push(format!("serializer: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = + serde_json::to_value(finding_request(logical_id, SccmRole::Client, reason)).unwrap(); + if serde_json::from_value::(json).is_err() { + rejected.push(format!("deserializer: {reason}")); + } + } + + assert!( + rejected.is_empty(), + "rejected bounded auxiliary confirmations: {rejected:#?}" + ); +} + +#[test] +fn finding_review_exact_mp_identity_passes_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-exact-mp-identity-parity"); + let mut rejected = Vec::new(); + + for (logical_id, reason) in REVIEW_EXACT_MP_ARTIFACT_REQUESTS { + let builder = SccmFindingBuilder::new("review-exact-mp-identity-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::ManagementPoint) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request( + logical_id, + SccmRole::ManagementPoint, + reason, + )) + .build(); + if let Err(error) = builder { + rejected.push(format!("builder ({error:?}): {logical_id}: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = finding_request(logical_id, SccmRole::ManagementPoint, reason); + if let Err(error) = direct.validate() { + rejected.push(format!( + "direct validate ({error:?}): {logical_id}: {reason}" + )); + } + if serde_json::to_value(&direct).is_err() { + rejected.push(format!("serializer: {logical_id}: {reason}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = serde_json::to_value(finding_request( + logical_id, + SccmRole::ManagementPoint, + reason, + )) + .unwrap(); + if serde_json::from_value::(json).is_err() { + rejected.push(format!("deserializer: {logical_id}: {reason}")); + } + } + + assert!( + rejected.is_empty(), + "rejected exact MP artifact request boundaries: {rejected:#?}" + ); +} + +#[test] +fn finding_review_every_exact_catalog_identity_passes_at_every_public_boundary() { + let canonical = finding_with_gap_and_request("review-exact-catalog-identity-parity"); + let mut rejected = Vec::new(); + + for source in declared_source_catalog() { + let reasons = [ + format!("Collect the complete {} file.", source.basename), + format!("Collect the complete {}.log file.", source.logical_name), + ]; + + for reason in reasons { + let request = finding_request(&source.logical_name, source.role.clone(), &reason); + let builder = SccmFindingBuilder::new("review-exact-catalog-identity-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(source.role.clone()) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(request.clone()) + .build(); + if let Err(error) = builder { + rejected.push(format!( + "builder ({error:?}): {}: {reason}", + source.logical_name + )); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = request.clone(); + if let Err(error) = direct.validate() { + rejected.push(format!( + "direct validate ({error:?}): {}: {reason}", + source.logical_name + )); + } + if serde_json::to_value(&direct).is_err() { + rejected.push(format!("serializer: {}: {reason}", source.logical_name)); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = serde_json::to_value(request).unwrap(); + if serde_json::from_value::(json).is_err() { + rejected.push(format!("deserializer: {}: {reason}", source.logical_name)); + } + } + } + + assert!( + rejected.is_empty(), + "rejected exact catalog artifact request boundaries: {rejected:#?}" + ); +} + +#[test] +fn finding_request_accepts_exact_multi_dot_catalog_basename_at_every_public_boundary() { + let reason = "Collect the complete client.msi.log file."; + let request = finding_request("clientMsi", SccmRole::Client, reason); + let canonical = finding_with_gap_and_request("exact-multi-dot-catalog-identity-parity"); + let mut rejected = Vec::new(); + + let builder = SccmFindingBuilder::new("exact-multi-dot-catalog-identity-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(request.clone()) + .build(); + if let Err(error) = builder { + rejected.push(format!("builder ({error:?})")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = request.clone(); + if let Err(error) = direct.validate() { + rejected.push(format!("direct validate ({error:?})")); + } + if let Err(error) = serde_json::to_value(&direct) { + rejected.push(format!("serializer ({error})")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = serde_json::to_value(request).unwrap(); + if let Err(error) = serde_json::from_value::(json) { + rejected.push(format!("deserializer ({error})")); + } + + assert!( + rejected.is_empty(), + "rejected exact multi-dot catalog identity: {rejected:#?}" + ); +} + +#[test] +fn finding_request_rejects_multi_dot_scope_without_exact_catalog_authorization() { + let canonical = finding_with_gap_and_request("invalid-multi-dot-catalog-identity-parity"); + let cases = [ + ( + "unknown multi-dot basename", + finding_request( + "clientMsi", + SccmRole::Client, + "Collect the complete client.unknown.log file.", + ), + SccmFindingValidationError::InvalidArtifactRequestReason, + ), + ( + "bare component of the multi-dot basename", + finding_request( + "clientMsi", + SccmRole::Client, + "Collect the complete client.log file.", + ), + SccmFindingValidationError::InvalidArtifactRequestReason, + ), + ( + "mismatched logical id", + finding_request( + "policyAgent", + SccmRole::Client, + "Collect the complete client.msi.log file.", + ), + SccmFindingValidationError::InvalidArtifactRequestReason, + ), + ( + "mismatched role", + finding_request( + "clientMsi", + SccmRole::ManagementPoint, + "Collect the complete client.msi.log file.", + ), + SccmFindingValidationError::ArtifactRequestRoleMismatch, + ), + ( + "glob", + finding_request( + "clientMsi", + SccmRole::Client, + "Collect client.msi*.log from the bundle.", + ), + SccmFindingValidationError::InvalidArtifactRequestReason, + ), + ( + "unbounded language", + finding_request( + "clientMsi", + SccmRole::Client, + "Collect client.msi.log and every file on the system.", + ), + SccmFindingValidationError::InvalidArtifactRequestReason, + ), + ]; + let mut incorrectly_accepted = Vec::new(); + + for (label, request, expected_error) in cases { + let builder = SccmFindingBuilder::new("invalid-multi-dot-catalog-identity-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(request.clone()) + .build(); + if builder.err() != Some(expected_error) { + incorrectly_accepted.push(format!("builder: {label}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = request.clone(); + if direct.validate().err() != Some(expected_error) { + incorrectly_accepted.push(format!("direct validate: {label}")); + } + if serde_json::to_value(&direct).is_ok() { + incorrectly_accepted.push(format!("serializer: {label}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = serde_json::json!({ + "logicalId": &request.logical_id, + "role": &request.role, + "reason": &request.reason, + }); + let deserialized = serde_json::from_value::(json); + let expected_message = format!("invalid SCCM finding contract: {expected_error:?}"); + let matches_expected = deserialized + .err() + .is_some_and(|error| error.to_string() == expected_message); + if !matches_expected { + incorrectly_accepted.push(format!("deserializer: {label}")); + } + } + + assert!( + incorrectly_accepted.is_empty(), + "accepted or misclassified unauthorized multi-dot requests: {incorrectly_accepted:#?}" + ); +} + +#[test] +fn finding_review_percentages_are_not_environment_paths_at_every_public_boundary() { + let reason = "Collect PolicyAgent.log after 50% and before 60% completion."; + let canonical = finding_with_gap_and_request("review-percentage-path-parity"); + let mut rejected = Vec::new(); + + let builder = SccmFindingBuilder::new("review-percentage-path-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + if let Err(error) = builder { + rejected.push(format!("builder ({error:?})")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + if let Err(error) = direct.validate() { + rejected.push(format!("direct validate ({error:?})")); + } + if serde_json::to_value(&direct).is_err() { + rejected.push("serializer".into()); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + if serde_json::from_value::(json).is_err() { + rejected.push("deserializer".into()); + } + + assert!( + rejected.is_empty(), + "rejected non-environment percentages: {rejected:#?}" + ); +} + +#[test] +fn finding_rejects_a_correlation_key_without_an_evidence_ref() { + let cited = finding_evidence_ref("client-policy-agent", "policy:1-1"); + let mut key = finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + cited.clone(), + ); + key.evidence = None; + + let result = SccmFindingBuilder::new("missing-key-evidence") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![cited]) + .correlation_keys(vec![key]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::CorrelationKeyMissingEvidence + ); +} + +#[test] +fn finding_low_profiled_or_unprofiled_keys_never_corroborate_high_confidence() { + let first = finding_evidence_ref("client-content", "client-content:1-1"); + let second = finding_evidence_ref("server-content", "server-content:1-1"); + let cases = [ + ( + "low-profiled-keys", + Some("sccm-keys-experimental-v1"), + SccmKeyConfidence::Low, + ), + ("low-unprofiled-keys", None, SccmKeyConfidence::Low), + ]; + + for (finding_id, profile, confidence) in cases { + let result = SccmFindingBuilder::new(finding_id) + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Content) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![first.clone(), second.clone()]) + .correlation_keys(vec![ + finding_key( + SccmCorrelationKeyKind::ContentId, + "ContentABC", + "contentabc", + confidence.clone(), + profile, + first.clone(), + ), + finding_key( + SccmCorrelationKeyKind::ContentId, + "contentabc", + "contentabc", + confidence.clone(), + profile, + second.clone(), + ), + ]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::MissingTerminalEvidence, + "{finding_id}" + ); + } +} + +#[test] +fn finding_rejects_forged_terminal_markers() { + let evidence = finding_evidence_ref("client-app-enforce", "client-app-enforce:1-1"); + let result = SccmFindingBuilder::new("forged-terminal") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![evidence.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence { + reference: evidence, + kind: SccmTerminalEvidenceKind::Unknown("observedFailure".into()), + }]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidTerminalEvidence + ); +} + +#[test] +fn finding_likely_contributor_is_capped_without_terminal_corroboration() { + let evidence = finding_evidence_ref("client-policy-agent", "policy:1-1"); + let high = SccmFindingBuilder::new("likely-contributor-high") + .class(SccmFindingClass::LikelyContributor) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::High) + .evidence(vec![evidence.clone()]) + .build(); + assert_eq!( + high.unwrap_err(), + SccmFindingValidationError::LikelyContributorConfidenceTooHigh + ); + + let moderate = SccmFindingBuilder::new("likely-contributor-moderate") + .class(SccmFindingClass::LikelyContributor) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Moderate) + .evidence(vec![evidence.clone()]) + .build() + .unwrap(); + assert_eq!(moderate.confidence, SccmConfidence::Moderate); + + let terminal = SccmFindingBuilder::new("likely-contributor-terminal") + .class(SccmFindingClass::LikelyContributor) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![evidence.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(evidence)]) + .build() + .unwrap(); + assert_eq!(terminal.confidence, SccmConfidence::High); +} + +#[test] +fn finding_evidence_less_claims_are_rejected() { + let result = SccmFindingBuilder::new("unsupported-success-claim") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Success) + .confidence(SccmConfidence::High) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::MissingEvidenceOrCoverageGap + ); +} + +#[test] +fn finding_access_denied_coverage_only_cannot_substantiate_an_outcome_class() { + let canonical = SccmFindingBuilder::new("access-denied-insufficient-evidence") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::AccessDenied, + )) + .next_artifact(finding_request( + "policyAgent", + SccmRole::Client, + "Policy evidence was not captured.", + )) + .build() + .unwrap(); + let cases = [ + (SccmFindingClass::Symptom, SccmConfidence::High, "symptom"), + ( + SccmFindingClass::LikelyContributor, + SccmConfidence::Moderate, + "likelyContributor", + ), + ( + SccmFindingClass::ConfirmedFailure, + SccmConfidence::Moderate, + "confirmedFailure", + ), + ( + SccmFindingClass::Recovered, + SccmConfidence::High, + "recovered", + ), + ( + SccmFindingClass::ContradictoryEvidence, + SccmConfidence::Low, + "contradictoryEvidence", + ), + ( + SccmFindingClass::BlockedOrDeferred, + SccmConfidence::Low, + "blockedOrDeferred", + ), + ]; + let mut accepted = Vec::new(); + + for (class, confidence, label) in cases { + let builder = SccmFindingBuilder::new(format!("access-denied-builder-{label}")) + .class(class.clone()) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(confidence) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::AccessDenied, + )) + .next_artifact(finding_request( + "policyAgent", + SccmRole::Client, + "Policy evidence was not captured.", + )) + .build(); + if builder.err() != Some(SccmFindingValidationError::MissingEvidenceOrCoverageGap) { + accepted.push(format!("builder: {label}")); + } + + let mut direct = canonical.clone(); + direct.class = class; + direct.confidence = confidence; + if direct.validate().err() != Some(SccmFindingValidationError::MissingEvidenceOrCoverageGap) + { + accepted.push(format!("direct validate: {label}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {label}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["class"] = serde_json::json!(label); + json["confidence"] = serde_json::to_value(direct.confidence).unwrap(); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {label}")); + } + } + + assert!( + accepted.is_empty(), + "coverage-only access denial substantiated outcome classes: {accepted:#?}" + ); +} + +#[test] +fn finding_insufficient_evidence_requires_an_explicit_noncaptured_gap() { + let request = finding_request( + "policyAgent", + SccmRole::Client, + "Policy evidence was not captured.", + ); + let missing_gap = SccmFindingBuilder::new("missing-gap") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .next_artifact(request.clone()) + .build(); + assert_eq!( + missing_gap.unwrap_err(), + SccmFindingValidationError::MissingCoverageGap + ); + + let captured_is_not_a_gap = SccmFindingBuilder::new("captured-is-not-gap") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::Captured, + )) + .next_artifact(request) + .build(); + assert_eq!( + captured_is_not_a_gap.unwrap_err(), + SccmFindingValidationError::InvalidCoverageGap + ); +} + +#[test] +fn finding_rejects_conflicting_coverage_for_one_artifact_identity() { + let first = finding_client_gap("client-policy-agent", SccmCoverageState::Absent); + let conflicts = [ + ( + "state", + vec![ + first.clone(), + finding_client_gap("client-policy-agent", SccmCoverageState::AccessDenied), + ], + ), + ( + "role", + vec![ + first.clone(), + SccmFindingCoverageGap { + artifact_id: "client-policy-agent".into(), + role: SccmRole::ManagementPoint, + coverage: SccmCoverageState::Absent, + }, + ], + ), + ]; + let mut accepted = Vec::new(); + + for (label, gaps) in conflicts { + let builder = SccmFindingBuilder::new(format!("conflicting-coverage-builder-{label}")) + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .coverage_gaps(gaps.clone()) + .build(); + if builder.err() != Some(SccmFindingValidationError::InvalidCoverageGap) { + accepted.push(format!("builder: {label}")); + } + + let mut direct = + finding_with_gap_and_request(&format!("conflicting-coverage-direct-{label}")); + direct.coverage_gaps = gaps.clone(); + if direct.validate().err() != Some(SccmFindingValidationError::InvalidCoverageGap) { + accepted.push(format!("direct validate: {label}")); + } + if serde_json::to_value(&direct).is_ok() { + accepted.push(format!("serializer: {label}")); + } + + let mut json = serde_json::to_value(finding_with_gap_and_request(&format!( + "conflicting-coverage-json-{label}" + ))) + .unwrap(); + json["coverageGaps"] = serde_json::to_value(gaps).unwrap(); + if serde_json::from_value::(json).is_ok() { + accepted.push(format!("deserializer: {label}")); + } + } + + assert!( + accepted.is_empty(), + "accepted conflicting coverage: {accepted:#?}" + ); + + let duplicate = finding_client_gap("client-policy-agent", SccmCoverageState::AccessDenied); + let built = SccmFindingBuilder::new("duplicate-coverage-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .coverage_gaps(vec![duplicate.clone(), duplicate.clone()]) + .build() + .unwrap(); + assert_eq!(built.coverage_gaps, vec![duplicate.clone()]); + + let mut direct = built.clone(); + direct.coverage_gaps = vec![duplicate.clone(), duplicate]; + direct.validate().unwrap(); + let serialized = serde_json::to_value(&direct).unwrap(); + assert_eq!(serialized["coverageGaps"].as_array().unwrap().len(), 1); + let deserialized = serde_json::from_value::(serialized).unwrap(); + assert_eq!(deserialized.coverage_gaps.len(), 1); +} + +#[test] +fn finding_artifact_requests_require_declared_logical_id_and_role() { + for invalid_id in [ + "client-policy-agent", + r"C:\", + "D:/", + "/", + "*", + "**/*.log", + "whole disk", + "PolicyAgent.log", + ] { + let result = SccmFindingBuilder::new("invalid-request-id") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::Absent, + )) + .next_artifact(finding_request( + invalid_id, + SccmRole::Client, + "Policy evidence was not captured.", + )) + .build(); + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::UndeclaredArtifactRequest, + "{invalid_id}" + ); + } + + let role_mismatch = SccmFindingBuilder::new("invalid-request-role") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::Absent, + )) + .next_artifact(finding_request( + "policyAgent", + SccmRole::ManagementPoint, + "Policy evidence was not captured.", + )) + .build(); + assert_eq!( + role_mismatch.unwrap_err(), + SccmFindingValidationError::ArtifactRequestRoleMismatch + ); +} + +#[test] +fn finding_artifact_requests_require_nonempty_bounded_reasons_and_count() { + for reason in ["", " ", ".", ";", "!?"] { + let result = SccmFindingBuilder::new("empty-request-reason") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::Absent, + )) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidArtifactRequestReason + ); + } + + let overlong_reason = "x".repeat(MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS + 1); + let overlong = SccmFindingBuilder::new("overlong-request-reason") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::Absent, + )) + .next_artifact(finding_request( + "policyAgent", + SccmRole::Client, + &overlong_reason, + )) + .build(); + assert_eq!( + overlong.unwrap_err(), + SccmFindingValidationError::InvalidArtifactRequestReason + ); + + let requests = (0..=MAX_SCCM_NEXT_ARTIFACT_REQUESTS) + .map(|index| { + finding_request( + "policyAgent", + SccmRole::Client, + &format!("Bounded request {index}"), + ) + }) + .collect(); + let too_many = SccmFindingBuilder::new("too-many-requests") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::Absent, + )) + .next_artifacts(requests) + .build(); + assert_eq!( + too_many.unwrap_err(), + SccmFindingValidationError::TooManyArtifactRequests + ); +} + +#[test] +fn finding_artifact_request_raw_cardinality_precedes_exact_deduplication() { + let canonical = finding_with_gap_and_request("duplicate-request-cardinality"); + let request = canonical.next_artifacts[0].clone(); + let duplicated = vec![request; MAX_SCCM_NEXT_ARTIFACT_REQUESTS + 1]; + + let builder = SccmFindingBuilder::new("duplicate-request-builder") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::AccessDenied, + )) + .next_artifacts(duplicated.clone()) + .build(); + assert_eq!( + builder.unwrap_err(), + SccmFindingValidationError::TooManyArtifactRequests + ); + + let mut direct = canonical.clone(); + direct.next_artifacts = duplicated; + assert_eq!( + direct.validate().unwrap_err(), + SccmFindingValidationError::TooManyArtifactRequests + ); + + let mut json = serde_json::to_value(canonical).unwrap(); + let request_json = json["nextArtifacts"][0].clone(); + json["nextArtifacts"] = + serde_json::Value::Array(vec![request_json; MAX_SCCM_NEXT_ARTIFACT_REQUESTS + 1]); + assert!(serde_json::from_value::(json).is_err()); + + let error = serde_json::to_string(&direct).unwrap_err().to_string(); + assert!(error.contains("TooManyArtifactRequests"), "{error}"); +} + +#[test] +fn finding_artifact_requests_reject_unbounded_reason_language_and_globs() { + for reason in [ + "Collect the entire drive.", + "Search the whole disk for related evidence.", + "Collect all files recursively.", + "Recursively scan the client logs.", + "Collect C:\\**\\*.log.", + ] { + let result = SccmFindingBuilder::new("unbounded-request-reason") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::Absent, + )) + .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidArtifactRequestReason, + "{reason}" + ); + } +} + +#[test] +fn finding_deserialization_rejects_unsound_high_and_forged_terminal_state() { + let evidence = finding_evidence_ref("client-app-enforce", "client-app-enforce:1-1"); + let sound = SccmFindingBuilder::new("sound-terminal") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![evidence.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(evidence)]) + .build() + .unwrap(); + + let mut keyless_high = serde_json::to_value(&sound).unwrap(); + keyless_high["terminalEvidence"] = serde_json::json!([]); + assert!(serde_json::from_value::(keyless_high).is_err()); + + let mut forged_terminal = serde_json::to_value(&sound).unwrap(); + forged_terminal["terminalEvidence"][0]["kind"] = serde_json::json!("forgedFailure"); + assert!(serde_json::from_value::(forged_terminal).is_err()); +} + +#[test] +fn finding_builder_rejects_blank_self_attesting_terminal_identity() { + let invalid = SccmEvidenceRef { + artifact_id: String::new(), + entry_id: " ".into(), + line_start: None, + line_end: None, + }; + let result = SccmFindingBuilder::new("blank-terminal-builder") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![invalid.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(invalid)]) + .build(); + + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidEvidenceReference + ); +} + +#[test] +fn finding_deserialization_rejects_blank_self_attesting_terminal_identity() { + let evidence = finding_evidence_ref("client-app-enforce", "client-app-enforce:1-1"); + let finding = SccmFindingBuilder::new("blank-terminal-deserialize") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![evidence.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(evidence)]) + .build() + .unwrap(); + let mut json = serde_json::to_value(finding).unwrap(); + json["evidence"][0]["artifactId"] = serde_json::json!(" "); + json["evidence"][0]["entryId"] = serde_json::json!(""); + json["terminalEvidence"][0]["reference"]["artifactId"] = serde_json::json!(" "); + json["terminalEvidence"][0]["reference"]["entryId"] = serde_json::json!(""); + + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn finding_serialization_rejects_blank_self_attesting_terminal_identity() { + let evidence = finding_evidence_ref("client-app-enforce", "client-app-enforce:1-1"); + let mut finding = SccmFindingBuilder::new("blank-terminal-serialize") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![evidence.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(evidence)]) + .build() + .unwrap(); + finding.evidence[0].artifact_id = " ".into(); + finding.evidence[0].entry_id.clear(); + finding.terminal_evidence[0].reference.artifact_id = " ".into(); + finding.terminal_evidence[0].reference.entry_id.clear(); + + assert!(serde_json::to_value(finding).is_err()); +} + +#[test] +fn finding_serialization_rejects_post_build_invalid_mutation() { + let evidence = finding_evidence_ref("client-app-enforce", "client-app-enforce:1-1"); + let mut finding = SccmFindingBuilder::new("mutated-confirmed-failure") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![evidence.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(evidence)]) + .build() + .unwrap(); + finding.terminal_evidence.clear(); + + assert!(serde_json::to_value(finding).is_err()); +} + +#[test] +fn finding_deserialization_sorts_and_deduplicates_terminal_evidence() { + let first = finding_evidence_ref("artifact-a", "entry-a"); + let second = finding_evidence_ref("artifact-b", "entry-b"); + let finding = SccmFindingBuilder::new("terminal-ordering") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![first.clone(), second.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure(first.clone())]) + .build() + .unwrap(); + let first_terminal = + serde_json::to_value(SccmTerminalEvidence::observed_failure(first)).unwrap(); + let second_terminal = + serde_json::to_value(SccmTerminalEvidence::observed_failure(second)).unwrap(); + let mut json = serde_json::to_value(finding).unwrap(); + json["terminalEvidence"] = + serde_json::json!([second_terminal, first_terminal.clone(), first_terminal]); + + let normalized: SccmFinding = serde_json::from_value(json).unwrap(); + assert_eq!(normalized.terminal_evidence.len(), 2); + assert_eq!( + normalized.terminal_evidence[0].reference.artifact_id, + "artifact-a" + ); + assert_eq!( + normalized.terminal_evidence[1].reference.artifact_id, + "artifact-b" + ); +} + +#[test] +fn finding_deserialization_rejects_raw_execution_context_fields() { + let evidence = finding_evidence_ref("client-policy-agent", "policy:1-1"); + let finding = SccmFindingBuilder::new("no-raw-context") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![evidence]) + .build() + .unwrap(); + let mut json = serde_json::to_value(finding).unwrap(); + json["executionContext"] = serde_json::json!(r"LAB\SyntheticUser"); + + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn finding_deserialization_rejects_unknown_fields_recursively() { + let evidence = finding_evidence_ref("client-policy-agent", "policy:1-1"); + let finding = SccmFindingBuilder::new("strict-finding-wire") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![evidence.clone()]) + .terminal_evidence(vec![SccmTerminalEvidence::observed_failure( + evidence.clone(), + )]) + .coverage_gap(finding_client_gap( + "client-policy-agent", + SccmCoverageState::AccessDenied, + )) + .correlation_keys(vec![finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + evidence, + )]) + .next_artifact(finding_request( + "policyAgent", + SccmRole::Client, + "Confirm the bounded policy request outcome.", + )) + .build() + .unwrap(); + let json = serde_json::to_value(finding).unwrap(); + + let mut cases = Vec::new(); + + let mut nested_evidence = json.clone(); + nested_evidence["evidence"][0]["executionContext"] = serde_json::json!(r"LAB\SyntheticUser"); + cases.push(("evidence", nested_evidence)); + + let mut terminal_evidence = json.clone(); + terminal_evidence["terminalEvidence"][0]["executionContext"] = + serde_json::json!(r"LAB\SyntheticUser"); + cases.push(("terminal evidence", terminal_evidence)); + + let mut terminal_reference = json.clone(); + terminal_reference["terminalEvidence"][0]["reference"]["executionContext"] = + serde_json::json!(r"LAB\SyntheticUser"); + cases.push(("terminal reference", terminal_reference)); + + let mut coverage_gap = json.clone(); + coverage_gap["coverageGaps"][0]["executionContext"] = serde_json::json!(r"LAB\SyntheticUser"); + cases.push(("coverage gap", coverage_gap)); + + let mut correlation_key = json.clone(); + correlation_key["correlationKeys"][0]["executionContext"] = + serde_json::json!(r"LAB\SyntheticUser"); + cases.push(("correlation key", correlation_key)); + + let mut correlation_key_reference = json.clone(); + correlation_key_reference["correlationKeys"][0]["evidence"]["executionContext"] = + serde_json::json!(r"LAB\SyntheticUser"); + cases.push(("correlation key reference", correlation_key_reference)); + + let mut artifact_request = json; + artifact_request["nextArtifacts"][0]["executionContext"] = + serde_json::json!(r"LAB\SyntheticUser"); + cases.push(("artifact request", artifact_request)); + + for (label, case) in cases { + assert!( + serde_json::from_value::(case).is_err(), + "{label} accepted an undeclared nested field" + ); + } +} + +#[test] +fn finding_output_is_sorted_deduplicated_camel_case_and_round_trippable() { + let first = finding_evidence_ref("artifact-a", "entry-a"); + let second = finding_evidence_ref("artifact-b", "entry-b"); + let package_key = finding_key( + SccmCorrelationKeyKind::PackageId, + "LAB00001", + "LAB00001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + second.clone(), + ); + let assignment_key = finding_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + SccmKeyConfidence::Low, + Some("sccm-keys-experimental-v1"), + first.clone(), + ); + + let finding = SccmFindingBuilder::new("blocked-policy") + .class(SccmFindingClass::BlockedOrDeferred) + .phase(SccmPhase::Unknown("futurePhase".into())) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Moderate) + .title("Policy processing is blocked") + .summary("Synthetic evidence does not expose execution context.") + .evidence(vec![second.clone(), first.clone(), second.clone()]) + .correlation_keys(vec![ + package_key.clone(), + assignment_key.clone(), + package_key, + ]) + .coverage_gaps(vec![ + finding_client_gap("artifact-z", SccmCoverageState::Capped), + finding_client_gap("artifact-c", SccmCoverageState::AccessDenied), + finding_client_gap("artifact-z", SccmCoverageState::Capped), + ]) + .next_artifacts(vec![ + finding_request( + "policyEvaluator", + SccmRole::Client, + "Confirm the bounded policy evaluation outcome.", + ), + finding_request( + "policyAgent", + SccmRole::Client, + "Confirm the bounded policy request outcome.", + ), + finding_request( + "policyEvaluator", + SccmRole::Client, + "Confirm the bounded policy evaluation outcome.", + ), + ]) + .build() + .unwrap(); + + assert_eq!(finding.evidence, vec![first, second]); + assert_eq!( + finding + .correlation_keys + .iter() + .map(|key| key.kind.clone()) + .collect::>(), + vec![ + SccmCorrelationKeyKind::AssignmentId, + SccmCorrelationKeyKind::PackageId, + ] + ); + assert_eq!( + finding + .coverage_gaps + .iter() + .map(|gap| gap.artifact_id.as_str()) + .collect::>(), + vec!["artifact-c", "artifact-z"] + ); + assert_eq!( + finding + .next_artifacts + .iter() + .map(|request| request.logical_id.as_str()) + .collect::>(), + vec!["policyAgent", "policyEvaluator"] + ); + + let json = serde_json::to_value(&finding).unwrap(); + assert_eq!(json["findingId"], "blocked-policy"); + assert_eq!(json["class"], "blockedOrDeferred"); + assert_eq!(json["phase"], "futurePhase"); + assert!(json.get("coverageGaps").is_some()); + assert!(json.get("correlationKeys").is_some()); + assert!(json.get("nextArtifacts").is_some()); + assert!(json.get("executionContext").is_none()); + assert!(!serde_json::to_string(&json) + .unwrap() + .contains("SyntheticUser")); + + let round_trip: SccmFinding = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip, finding); + round_trip.validate().unwrap(); +} + +fn json_value_contains_sensitive(value: &serde_json::Value, sensitive: &str) -> bool { + match value { + serde_json::Value::String(value) => value.contains(sensitive), + serde_json::Value::Array(values) => values + .iter() + .any(|value| json_value_contains_sensitive(value, sensitive)), + serde_json::Value::Object(values) => values + .values() + .any(|value| json_value_contains_sensitive(value, sensitive)), + _ => false, + } +} + +fn public_json_contains_sensitive(json: &str, sensitive: &str) -> bool { + let decoded: serde_json::Value = serde_json::from_str(json).unwrap(); + let encoded = serde_json::to_string(sensitive).unwrap(); + let escaped = &encoded[1..encoded.len() - 1]; + + json_value_contains_sensitive(&decoded, sensitive) || json.contains(escaped) +} + +fn assert_public_json_omits(json: &str, sensitive: &str) { + assert!( + !public_json_contains_sensitive(json, sensitive), + "{sensitive} leaked in decoded or escaped public JSON" + ); +} + +#[test] +fn sccm_contract_is_public_and_versioned() { + assert_eq!(SCCM_DIAGNOSTICS_SCHEMA_VERSION, 1); + let artifact = SccmArtifact::missing( + "client-policy-agent", + "PolicyAgent.log", + SccmRole::Client, + SccmCoverageState::Absent, + ); + assert_eq!(artifact.coverage, SccmCoverageState::Absent); + assert_eq!( + SccmFindingClass::InsufficientEvidence.as_str(), + "insufficientEvidence" + ); +} + +#[test] +fn public_ccm_multiline_projection_stays_compatible() { + let text = include_str!("fixtures/sccm/spine/multiline-policy.log"); + let (entries, errors) = + cmtraceopen_parser::parser::ccm::parse_content(text, "PolicyAgent.log", None); + assert_eq!(errors, 0); + assert_eq!( + entries.len(), + 1, + "ordinary public CCM output stays unchanged" + ); + assert_eq!(entries[0].line_number, 1); + assert_eq!(entries[0].format, LogFormat::Ccm); + assert_eq!(entries[0].timezone_offset, Some(-240)); + assert!(entries[0] + .message + .contains("{11111111-1111-1111-1111-111111111111}")); + + let public_json = serde_json::to_value(&entries[0]).unwrap(); + assert!(public_json.get("context").is_none()); + assert!(!serde_json::to_string(&public_json) + .unwrap() + .contains(r"NT AUTHORITY\\SYSTEM")); +} + +#[test] +fn public_ccm_single_line_projection_matches_line_parser() { + let text = r#""#; + let (content_entries, content_errors) = + cmtraceopen_parser::parser::ccm::parse_content(text, "PolicyAgent.log", None); + let (line_entries, line_errors) = + cmtraceopen_parser::parser::ccm::parse_lines(&[text], "PolicyAgent.log"); + + assert_eq!(content_errors, line_errors); + assert_eq!( + serde_json::to_vec(&content_entries).unwrap(), + serde_json::to_vec(&line_entries).unwrap() + ); +} + +#[test] +fn signal_extractor_preserves_known_hresult_and_error_db_metadata() { + let signals = extract_signals("Download failed with hr=0x80070005"); + + assert_eq!(signals.len(), 1); + assert_eq!(signals[0].kind, SccmSignalKind::HResult); + assert_eq!(signals[0].raw, "0x80070005"); + assert_eq!(signals[0].numeric, Some(0x80070005)); + assert!(signals[0].error_description.is_some()); + assert!(signals[0].error_category.is_some()); +} + +#[test] +fn signal_extractor_preserves_unknown_exit_and_gle_values() { + let signals = extract_signals("exit code 1603; [gle=0xDEADBEEF]; status=71"); + + assert_eq!( + signals + .iter() + .map(|signal| (&signal.kind, signal.raw.as_str())) + .collect::>(), + vec![ + (&SccmSignalKind::ExitCode, "1603"), + (&SccmSignalKind::Gle, "0xDEADBEEF"), + (&SccmSignalKind::Status, "71"), + ] + ); + assert!(signals + .iter() + .all(|signal| signal.error_description.is_none() || !signal.raw.is_empty())); + assert!(signals[0].error_description.is_some()); + assert_eq!(signals[1].numeric, Some(0xDEADBEEF)); + assert_eq!(signals[1].error_description, None); + assert_eq!(signals[1].error_category, None); +} + +#[test] +fn signal_extractor_does_not_enrich_decimal_values_as_unprefixed_hex() { + let signals = extract_signals("status=80004005"); + + assert_eq!(signals.len(), 1); + assert_eq!(signals[0].kind, SccmSignalKind::Status); + assert_eq!(signals[0].raw, "80004005"); + assert_eq!(signals[0].numeric, Some(80_004_005)); + assert_eq!(signals[0].error_description, None); + assert_eq!(signals[0].error_category, None); +} + +#[test] +fn signal_extractor_supports_only_the_declared_structured_forms() { + let signals = extract_signals( + "HRESULT 0x80004005; exitCode = 1618; return code 3010; \ + unstructured 0x80070005; id={80070005-1111-2222-3333-444444444444}", + ); + + assert_eq!( + signals + .iter() + .map(|signal| (&signal.kind, signal.raw.as_str())) + .collect::>(), + vec![ + (&SccmSignalKind::HResult, "0x80004005"), + (&SccmSignalKind::ExitCode, "1618"), + (&SccmSignalKind::ReturnCode, "3010"), + ] + ); +} + +#[test] +fn signal_extractor_uses_utf16_spans_and_preserves_repeated_tokens() { + let message = "😀 hr=0x80070005 then hr=0x80070005"; + let signals = extract_signals(message); + + assert_eq!(signals.len(), 2); + assert_eq!(signals[0].raw, signals[1].raw); + assert_ne!( + (signals[0].start, signals[0].end), + (signals[1].start, signals[1].end) + ); + assert_eq!((signals[0].start, signals[0].end), (6, 16)); + assert_eq!( + message + .encode_utf16() + .skip(signals[0].start) + .take(signals[0].end - signals[0].start) + .collect::>(), + "0x80070005".encode_utf16().collect::>() + ); +} + +#[test] +fn signal_extractor_is_deterministic_and_serializes_camel_case() { + let message = "HRESULT 0x80004005; status=4294967296"; + let first = extract_signals(message); + let second = extract_signals(message); + + assert_eq!(first, second); + assert_eq!(first[1].raw, "4294967296"); + assert_eq!(first[1].numeric, None); + assert_eq!(first[1].error_description, None); + + let json = serde_json::to_value(&first).unwrap(); + assert_eq!(json[0]["kind"], "hResult"); + assert_eq!(json[0]["raw"], "0x80004005"); + assert!(json[0]["errorDescription"].is_string()); + assert!(json[0]["errorCategory"].is_string()); + assert!(json[0]["start"].is_number()); + assert!(json[0]["end"].is_number()); + assert_eq!( + json, + serde_json::json!([ + { + "kind": "hResult", + "raw": "0x80004005", + "numeric": 2_147_500_037_u32, + "start": 8, + "end": 18, + "errorDescription": "E_FAIL - Unspecified failure", + "errorCategory": "Windows" + }, + { + "kind": "status", + "raw": "4294967296", + "numeric": null, + "start": 27, + "end": 37, + "errorDescription": null, + "errorCategory": null + } + ]) + ); + + let decoded: Vec = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(decoded, first); + assert_eq!(serde_json::to_value(&decoded).unwrap(), json); + + let kind_json = serde_json::to_string(&SccmSignalKind::Gle).unwrap(); + assert_eq!(kind_json, r#""gle""#); + let decoded_kind: SccmSignalKind = serde_json::from_str(&kind_json).unwrap(); + assert_eq!(decoded_kind, SccmSignalKind::Gle); +} + +#[test] +fn key_normalization_is_stable_across_case_and_brace_variants() { + let left = normalize_key( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + ); + let right = normalize_key( + SccmCorrelationKeyKind::AssignmentId, + "abcdefab-0000-0000-0000-000000000001", + ); + + assert_eq!(left.normalized, right.normalized); + assert_eq!(left.confidence, SccmKeyConfidence::Exact); +} + +#[test] +fn key_normalization_covers_each_declared_lexical_kind() { + let cases = [ + ( + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}", + "abcdefab-0000-0000-0000-000000000001", + ), + ( + SccmCorrelationKeyKind::ClientGuid, + "GUID:{ABCDEFAB-0000-0000-0000-000000000002}", + "abcdefab-0000-0000-0000-000000000002", + ), + (SccmCorrelationKeyKind::PackageId, "lab00001", "LAB00001"), + ( + SccmCorrelationKeyKind::ContentId, + "Content_ABC-123", + "content_abc-123", + ), + (SccmCorrelationKeyKind::SiteCode, "lab", "LAB"), + ( + SccmCorrelationKeyKind::ServerHost, + "MP01.LAB.LOCAL.", + "mp01.lab.local", + ), + (SccmCorrelationKeyKind::CiId, "00042", "42"), + ( + SccmCorrelationKeyKind::UpdateId, + "{ABCDEFAB-0000-0000-0000-000000000003}", + "abcdefab-0000-0000-0000-000000000003", + ), + (SccmCorrelationKeyKind::KbId, "kb5034441", "KB5034441"), + ( + SccmCorrelationKeyKind::BitsJobId, + "{ABCDEFAB-0000-0000-0000-000000000004}", + "abcdefab-0000-0000-0000-000000000004", + ), + ( + SccmCorrelationKeyKind::TaskSequenceExecutionId, + "{ABCDEFAB-0000-0000-0000-000000000005}", + "abcdefab-0000-0000-0000-000000000005", + ), + ( + SccmCorrelationKeyKind::RequestId, + "{ABCDEFAB-0000-0000-0000-000000000006}", + "abcdefab-0000-0000-0000-000000000006", + ), + ( + SccmCorrelationKeyKind::TopicId, + "{ABCDEFAB-0000-0000-0000-000000000007}", + "abcdefab-0000-0000-0000-000000000007", + ), + (SccmCorrelationKeyKind::StateMessageId, "00071", "71"), + ]; + + for (kind, raw, expected) in cases { + let key = normalize_key(kind, raw); + assert_eq!(key.normalized, expected, "{raw}"); + assert_eq!(key.confidence, SccmKeyConfidence::Exact, "{raw}"); + } +} + +#[test] +fn key_normalization_malformed_values_are_low_confidence_only() { + for (kind, raw) in [ + (SccmCorrelationKeyKind::AssignmentId, "{not-a-guid}"), + (SccmCorrelationKeyKind::PackageId, "LAB001"), + (SccmCorrelationKeyKind::ServerHost, "bad..host"), + (SccmCorrelationKeyKind::KbId, "KB-not-numeric"), + ] { + assert_eq!( + normalize_key(kind, raw).confidence, + SccmKeyConfidence::Low, + "{raw}" + ); + } +} + +#[test] +fn key_extraction_unvalidated_version_cannot_emit_exact_extracted_key() { + let result = extract_keys( + &evidence_with_message("Policy id={ABCDEFAB-0000-0000-0000-000000000001}"), + &SccmExtractionProfile::for_version(Some("unobserved-version")), + ); + + assert!(result.keys.is_empty()); + assert_eq!( + result.gaps[0].kind, + SccmExtractionGapKind::UnvalidatedVersion + ); + assert_eq!( + result.gaps[0].candidate_raw.as_deref(), + Some("{ABCDEFAB-0000-0000-0000-000000000001}") + ); +} + +#[test] +fn key_extraction_missing_version_is_an_explicit_gap_not_a_key() { + let result = extract_keys( + &evidence_with_message("package id=LAB00001"), + &SccmExtractionProfile::for_version(None), + ); + + assert!(result.keys.is_empty()); + assert_eq!(result.gaps[0].kind, SccmExtractionGapKind::MissingVersion); + assert_eq!(result.gaps[0].candidate_raw.as_deref(), Some("LAB00001")); +} + +#[test] +fn key_extraction_unvalidated_and_missing_versions_preserve_every_candidate_gap() { + let evidence = evidence_with_message( + "package id=LAB00001; site code=LAB; \ + assignment id={ABCDEFAB-0000-0000-0000-000000000001}", + ); + + for (profile, expected_gap_kind) in [ + ( + SccmExtractionProfile::for_version(Some("unobserved-version")), + SccmExtractionGapKind::UnvalidatedVersion, + ), + ( + SccmExtractionProfile::for_version(None), + SccmExtractionGapKind::MissingVersion, + ), + ] { + let first = extract_keys(&evidence, &profile); + let second = extract_keys(&evidence, &profile); + + assert_eq!(first, second); + assert!(first.keys.is_empty()); + assert_eq!(first.gaps.len(), 3); + assert!(first.gaps.iter().all(|gap| gap.kind == expected_gap_kind)); + assert_eq!( + first + .gaps + .iter() + .map(|gap| (gap.candidate_kind.clone(), gap.candidate_raw.as_deref())) + .collect::>(), + vec![ + (Some(SccmCorrelationKeyKind::PackageId), Some("LAB00001")), + (Some(SccmCorrelationKeyKind::SiteCode), Some("LAB")), + ( + Some(SccmCorrelationKeyKind::AssignmentId), + Some("{ABCDEFAB-0000-0000-0000-000000000001}") + ), + ] + ); + assert!(first + .gaps + .iter() + .all(|gap| gap.evidence == evidence.reference)); + } +} + +#[test] +fn key_extraction_rejects_truncated_prefixes_from_invalid_structured_values() { + let overlong_content_id = "a".repeat(129); + let invalid_guid = "{ABCDEFAB-0000-0000-0000-000000000001}extra"; + let invalid_host = "mp01.lab.local_suffix"; + let evidence = evidence_with_message(&format!( + "assignment id={invalid_guid}; content id={overlong_content_id}; \ + server host={invalid_host}" + )); + + let result = extract_keys( + &evidence, + &SccmExtractionProfile::for_version(Some("5.00.9128.1007")), + ); + + assert!(result.keys.is_empty()); + assert_eq!( + result + .gaps + .iter() + .filter(|gap| gap.kind == SccmExtractionGapKind::MalformedCandidate) + .map(|gap| gap.candidate_raw.as_deref()) + .collect::>(), + vec![ + Some(invalid_guid), + Some(overlong_content_id.as_str()), + Some(invalid_host), + ] + ); +} + +#[test] +fn key_extraction_requires_a_full_token_boundary_for_every_declared_kind() { + let cases = [ + ( + SccmCorrelationKeyKind::AssignmentId, + "assignment id={ABCDEFAB-0000-0000-0000-000000000001}}", + "{ABCDEFAB-0000-0000-0000-000000000001}}", + ), + ( + SccmCorrelationKeyKind::ClientGuid, + "client guid=GUID:{ABCDEFAB-0000-0000-0000-000000000002}/continued", + "GUID:{ABCDEFAB-0000-0000-0000-000000000002}/continued", + ), + ( + SccmCorrelationKeyKind::PackageId, + "package id=LAB00001é", + "LAB00001é", + ), + ( + SccmCorrelationKeyKind::ContentId, + "content id=ContentABC/continued", + "ContentABC/continued", + ), + ( + SccmCorrelationKeyKind::SiteCode, + "site code=LAB:continued", + "LAB:continued", + ), + ( + SccmCorrelationKeyKind::ServerHost, + "server host=mp01.lab.localé", + "mp01.lab.localé", + ), + ( + SccmCorrelationKeyKind::CiId, + "ci id=42+continued", + "42+continued", + ), + ( + SccmCorrelationKeyKind::UpdateId, + "update id={ABCDEFAB-0000-0000-0000-000000000003}:continued", + "{ABCDEFAB-0000-0000-0000-000000000003}:continued", + ), + ( + SccmCorrelationKeyKind::KbId, + "kb id=KB5034441/continued", + "KB5034441/continued", + ), + ( + SccmCorrelationKeyKind::BitsJobId, + "bits job id={ABCDEFAB-0000-0000-0000-000000000004}+continued", + "{ABCDEFAB-0000-0000-0000-000000000004}+continued", + ), + ( + SccmCorrelationKeyKind::TaskSequenceExecutionId, + "task sequence execution id={ABCDEFAB-0000-0000-0000-000000000005}}", + "{ABCDEFAB-0000-0000-0000-000000000005}}", + ), + ( + SccmCorrelationKeyKind::RequestId, + "request id={ABCDEFAB-0000-0000-0000-000000000006}/continued", + "{ABCDEFAB-0000-0000-0000-000000000006}/continued", + ), + ( + SccmCorrelationKeyKind::TopicId, + "topic id={ABCDEFAB-0000-0000-0000-000000000007}:continued", + "{ABCDEFAB-0000-0000-0000-000000000007}:continued", + ), + ( + SccmCorrelationKeyKind::StateMessageId, + "state message id=71é", + "71é", + ), + ]; + let profile = SccmExtractionProfile::for_version(Some("5.00.9128.1007")); + let mut violations = Vec::new(); + + for (expected_kind, message, expected_raw) in cases { + let evidence = evidence_with_message(message); + let first = extract_keys(&evidence, &profile); + let second = extract_keys(&evidence, &profile); + + assert_eq!(first, second, "{message}"); + let malformed = first + .gaps + .iter() + .filter(|gap| gap.kind == SccmExtractionGapKind::MalformedCandidate) + .map(|gap| (gap.candidate_kind.clone(), gap.candidate_raw.as_deref())) + .collect::>(); + if !first.keys.is_empty() || malformed != vec![(Some(expected_kind), Some(expected_raw))] { + violations.push(format!("{message}: {first:?}")); + } + } + + assert!( + violations.is_empty(), + "truncated key prefixes were admitted:\n{}", + violations.join("\n") + ); +} + +#[test] +fn key_extraction_rejects_every_label_inside_a_preceding_malformed_token() { + let second_labels = [ + ( + SccmCorrelationKeyKind::AssignmentId, + "assignment id={ABCDEFAB-0000-0000-0000-000000000001}", + ), + ( + SccmCorrelationKeyKind::ClientGuid, + "client guid=GUID:{ABCDEFAB-0000-0000-0000-000000000002}", + ), + (SccmCorrelationKeyKind::PackageId, "package id=LAB00002"), + (SccmCorrelationKeyKind::ContentId, "content id=ContentABC"), + (SccmCorrelationKeyKind::SiteCode, "site code=LAB"), + ( + SccmCorrelationKeyKind::ServerHost, + "server host=mp01.lab.local", + ), + (SccmCorrelationKeyKind::CiId, "ci id=42"), + ( + SccmCorrelationKeyKind::UpdateId, + "update id={ABCDEFAB-0000-0000-0000-000000000003}", + ), + (SccmCorrelationKeyKind::KbId, "kb id=KB5034441"), + ( + SccmCorrelationKeyKind::BitsJobId, + "bits job id={ABCDEFAB-0000-0000-0000-000000000004}", + ), + ( + SccmCorrelationKeyKind::TaskSequenceExecutionId, + "task sequence execution id={ABCDEFAB-0000-0000-0000-000000000005}", + ), + ( + SccmCorrelationKeyKind::RequestId, + "request id={ABCDEFAB-0000-0000-0000-000000000006}", + ), + ( + SccmCorrelationKeyKind::TopicId, + "topic id={ABCDEFAB-0000-0000-0000-000000000007}", + ), + ( + SccmCorrelationKeyKind::StateMessageId, + "state message id=71", + ), + ]; + let forbidden_delimiters = ["/", ":", "+"]; + let profile = SccmExtractionProfile::for_version(Some("5.00.9128.1007")); + let mut violations = Vec::new(); + + for (index, (second_kind, second_label)) in second_labels.into_iter().enumerate() { + let delimiter = forbidden_delimiters[index % forbidden_delimiters.len()]; + let message = format!("package id=LAB00001{delimiter}{second_label}"); + let malformed_raw = format!( + "LAB00001{delimiter}{}", + second_label.split_whitespace().next().unwrap() + ); + let evidence = evidence_with_message(&message); + let first = extract_keys(&evidence, &profile); + let second = extract_keys(&evidence, &profile); + let malformed = first + .gaps + .iter() + .filter(|gap| gap.kind == SccmExtractionGapKind::MalformedCandidate) + .map(|gap| { + ( + gap.candidate_kind.clone(), + gap.candidate_raw.as_deref(), + gap.evidence.clone(), + ) + }) + .collect::>(); + let expected_malformed = vec![( + Some(SccmCorrelationKeyKind::PackageId), + Some(malformed_raw.as_str()), + evidence.reference.clone(), + )]; + + if first != second || !first.keys.is_empty() || malformed != expected_malformed { + violations.push(format!("{second_kind:?}: {message}: {first:?}")); + } + } + + for (message, malformed_kind, malformed_raw) in [ + ( + "assignment id={ABCDEFAB-0000-0000-0000-000000000001}}content id=ContentABC", + SccmCorrelationKeyKind::AssignmentId, + "{ABCDEFAB-0000-0000-0000-000000000001}}content", + ), + ( + "package id=LAB00001écontent id=ContentABC", + SccmCorrelationKeyKind::PackageId, + "LAB00001écontent", + ), + ] { + let result = extract_keys(&evidence_with_message(message), &profile); + let malformed = result + .gaps + .iter() + .find(|gap| gap.kind == SccmExtractionGapKind::MalformedCandidate); + if !result.keys.is_empty() + || malformed.and_then(|gap| gap.candidate_kind.clone()) != Some(malformed_kind) + || malformed.and_then(|gap| gap.candidate_raw.as_deref()) != Some(malformed_raw) + { + violations.push(format!("{message}: {result:?}")); + } + } + + let unvalidated_evidence = evidence_with_message("package id=LAB00001/content id=ContentABC"); + let unvalidated = extract_keys( + &unvalidated_evidence, + &SccmExtractionProfile::for_version(Some("unobserved-version")), + ); + if !unvalidated.keys.is_empty() + || unvalidated.gaps.len() != 1 + || unvalidated.gaps[0].kind != SccmExtractionGapKind::UnvalidatedVersion + || unvalidated.gaps[0].candidate_kind != Some(SccmCorrelationKeyKind::PackageId) + || unvalidated.gaps[0].candidate_raw.as_deref() != Some("LAB00001/content") + || unvalidated.gaps[0].evidence != unvalidated_evidence.reference + { + violations.push(format!("unvalidated profile: {unvalidated:?}")); + } + + assert!( + violations.is_empty(), + "labels escaped from malformed tokens:\n{}", + violations.join("\n") + ); +} + +#[test] +fn key_extraction_accepts_the_declared_full_token_boundaries() { + let profile = SccmExtractionProfile::for_version(Some("5.00.9128.1007")); + + for separator in [" ", "\n", "\t", ",", ";", "&"] { + let message = format!("😀 package id=LAB00001{separator}content id=ContentABC"); + let result = extract_keys(&evidence_with_message(&message), &profile); + + assert_eq!(result.keys.len(), 2, "{message:?}"); + assert_eq!(result.keys[0].kind, SccmCorrelationKeyKind::PackageId); + assert_eq!(result.keys[0].raw, "LAB00001", "{message:?}"); + assert_eq!(result.keys[1].kind, SccmCorrelationKeyKind::ContentId); + assert_eq!(result.keys[1].raw, "ContentABC", "{message:?}"); + assert!(result + .keys + .iter() + .all(|key| key.confidence == SccmKeyConfidence::Low)); + for key in &result.keys { + let byte_start = message.find(&key.raw).unwrap(); + let expected_start = message[..byte_start].encode_utf16().count(); + assert_eq!(key.start, Some(expected_start), "{message:?}"); + assert_eq!( + key.end, + Some(expected_start + key.raw.encode_utf16().count()), + "{message:?}" + ); + } + assert!(result + .gaps + .iter() + .all(|gap| gap.kind != SccmExtractionGapKind::MalformedCandidate)); + } +} + +#[test] +fn key_profile_single_observed_version_stays_experimental_and_low_confidence() { + let profile = SccmExtractionProfile::for_version(Some("5.00.9128.1007")); + let evidence = evidence_with_message( + "Policy id={ABCDEFAB-0000-0000-0000-000000000001}; \ + package id=LAB00001; site code=LAB", + ); + let result = extract_keys(&evidence, &profile); + + assert_eq!( + profile.maturity, + SccmExtractionProfileMaturity::Experimental + ); + assert_eq!(profile.configmgr_version_prefixes, vec!["5.00.9128."]); + assert!(profile.validated_artifact_families.is_empty()); + assert_eq!(result.keys.len(), 3); + assert!(result + .keys + .iter() + .all(|key| key.confidence == SccmKeyConfidence::Low)); + assert!(result.keys.iter().all(|key| { + !matches!( + key.confidence, + SccmKeyConfidence::Strong | SccmKeyConfidence::Exact + ) + })); + assert_eq!( + result.gaps[0].kind, + SccmExtractionGapKind::ExperimentalProfile + ); + assert_eq!( + SccmExtractionProfile::for_version(Some("5.00.9135.1000")).maturity, + SccmExtractionProfileMaturity::Unvalidated + ); +} + +#[test] +fn key_profile_version_selection_rejects_malformed_or_prefix_collision_versions() { + for version in ["5.00.9128.not-observed", "5.00.91280.1007", "5.00.9128"] { + assert_eq!( + SccmExtractionProfile::for_version(Some(version)).maturity, + SccmExtractionProfileMaturity::Unvalidated, + "{version}" + ); + } +} + +#[test] +fn key_extraction_covers_declared_labels_in_message_order() { + let evidence = evidence_with_message( + "assignment id={ABCDEFAB-0000-0000-0000-000000000001}; \ + client guid=GUID:{ABCDEFAB-0000-0000-0000-000000000002}; \ + package id=LAB00001; content id=Content_ABC-123; site code=lab; \ + server host=MP01.LAB.LOCAL.; ci id=00042; \ + update id={ABCDEFAB-0000-0000-0000-000000000003}; kb id=KB5034441; \ + bits job id={ABCDEFAB-0000-0000-0000-000000000004}; \ + task sequence execution id={ABCDEFAB-0000-0000-0000-000000000005}; \ + request id={ABCDEFAB-0000-0000-0000-000000000006}; \ + topic id={ABCDEFAB-0000-0000-0000-000000000007}; state message id=00071", + ); + let result = extract_keys( + &evidence, + &SccmExtractionProfile::for_version(Some("5.00.9128.1007")), + ); + + assert_eq!( + result + .keys + .iter() + .map(|key| key.kind.clone()) + .collect::>(), + vec![ + SccmCorrelationKeyKind::AssignmentId, + SccmCorrelationKeyKind::ClientGuid, + SccmCorrelationKeyKind::PackageId, + SccmCorrelationKeyKind::ContentId, + SccmCorrelationKeyKind::SiteCode, + SccmCorrelationKeyKind::ServerHost, + SccmCorrelationKeyKind::CiId, + SccmCorrelationKeyKind::UpdateId, + SccmCorrelationKeyKind::KbId, + SccmCorrelationKeyKind::BitsJobId, + SccmCorrelationKeyKind::TaskSequenceExecutionId, + SccmCorrelationKeyKind::RequestId, + SccmCorrelationKeyKind::TopicId, + SccmCorrelationKeyKind::StateMessageId, + ] + ); +} + +#[test] +fn key_profile_forged_stable_profile_cannot_emit_strong_or_exact_keys() { + let mut profile = SccmExtractionProfile::for_version(Some("5.00.9128.1007")); + profile.maturity = SccmExtractionProfileMaturity::Stable; + + let result = extract_keys(&evidence_with_message("package id=LAB00001"), &profile); + + assert!(result.keys.is_empty()); + assert_eq!( + result.gaps[0].kind, + SccmExtractionGapKind::UnvalidatedProfile + ); +} + +#[test] +fn key_profile_and_extraction_result_have_deterministic_json_round_trips() { + let profile = SccmExtractionProfile::for_version(Some("5.00.9128.1007")); + let evidence = evidence_with_message("Policy id={ABCDEFAB-0000-0000-0000-000000000001}"); + let first = extract_keys(&evidence, &profile); + let second = extract_keys(&evidence, &profile); + + assert_eq!(first, second); + assert_eq!( + serde_json::to_value(&profile).unwrap(), + serde_json::json!({ + "profileId": "sccm-keys-5.00.9128-experimental-v1", + "configmgrVersionPrefixes": ["5.00.9128."], + "validatedArtifactFamilies": [], + "selectedConfigmgrVersion": "5.00.9128.1007", + "maturity": "experimental" + }) + ); + + let profile_json = serde_json::to_string(&profile).unwrap(); + assert_eq!( + serde_json::from_str::(&profile_json).unwrap(), + profile + ); + + let result_json = serde_json::to_string(&first).unwrap(); + assert_eq!( + serde_json::from_str::(&result_json).unwrap(), + first + ); +} + +#[test] +fn key_extraction_never_emits_a_key_its_own_contract_rejects() { + let mut mismatches: Vec = Vec::new(); + let profile = SccmExtractionProfile::for_version(Some("5.00.9128.1010")); + + // Both raws are inside the regexes' token grammar but outside the + // correlation-key value bound. The KB case is bounded as a raw and only + // crosses the bound once normalization prepends "KB", so the producer has + // to weigh the normalized value too. + for (label, message, raw) in [ + ( + "an overlong CI ID", + format!("CI ID={} status=71", "1".repeat(300)), + "1".repeat(300), + ), + ( + "a KB ID that normalizes past the bound", + format!("KB ID={}", "9".repeat(255)), + "9".repeat(255), + ), + ] { + let evidence = evidence_with_message(&message); + let result = extract_keys(&evidence, &profile); + + for key in &result.keys { + let key_json = serde_json::to_value(key).unwrap(); + if serde_json::from_value::(key_json) + .ok() + .as_ref() + != Some(key) + { + mismatches.push(format!( + "extract_keys emitted a key its own validator rejects for {label}" + )); + } + } + + let result_json = serde_json::to_value(&result).unwrap(); + if serde_json::from_value::(result_json) + .ok() + .as_ref() + != Some(&result) + { + mismatches.push(format!( + "extract_keys emitted a result that does not round trip for {label}" + )); + } + + // An out-of-bound candidate must stay visible as a gap rather than + // disappear, exactly as a candidate that fails to normalize does. + if !result.gaps.iter().any(|gap| { + gap.kind == SccmExtractionGapKind::MalformedCandidate + && gap.candidate_raw.as_deref() == Some(raw.as_str()) + }) { + mismatches.push(format!( + "extract_keys dropped the out-of-bound candidate for {label}" + )); + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); +} + +#[test] +fn public_ccm_malformed_continuation_stays_plain() { + let text = ""# + ); + let (entries, errors) = + cmtraceopen_parser::parser::ccm::parse_content(&text, "PolicyAgent.log", None); + let evidence = normalize_ccm_artifact(client_policy_artifact(), &text); + let expected_public_timestamp = chrono::NaiveDate::from_ymd_opt(2026, 7, 30) + .unwrap() + .and_hms_milli_opt(10, 0, 0, public_millis) + .unwrap() + .and_utc() + .timestamp_millis() + - i64::from(public_offset) * 60_000; + + assert_eq!(errors, 0, "{time_tail}"); + assert_eq!(entries.len(), 1, "{time_tail}"); + assert_eq!(entries[0].format, LogFormat::Ccm, "{time_tail}"); + assert_eq!( + entries[0].timestamp_display.as_deref(), + Some(public_display), + "{time_tail}" + ); + assert_eq!( + entries[0].timezone_offset, + Some(public_offset), + "{time_tail}" + ); + assert_eq!( + entries[0].timestamp, + Some(expected_public_timestamp), + "{time_tail}" + ); + assert_eq!(evidence.len(), 1, "{time_tail}"); + assert_eq!( + evidence[0].timestamp.original_display.as_deref(), + Some(evidence_display), + "{time_tail}" + ); + assert_eq!( + evidence[0].timestamp.offset_minutes, evidence_offset, + "{time_tail}" + ); + assert_eq!( + evidence[0].timestamp.ordering_state, evidence_state, + "{time_tail}" + ); + } +} + +#[test] +fn signless_ccm_offset_is_enriched_only_in_sccm_provenance() { + // CMTrace's documented `%03u%d` grammar permits the decimal offset to + // omit a sign: three millisecond digits followed by the offset. The + // public LogEntry keeps its pre-spine projection; only the additive SCCM + // timestamp provenance receives the corrected interpretation. + let text = r#""#; + let (entries, errors) = + cmtraceopen_parser::parser::ccm::parse_content(text, "PolicyAgent.log", None); + let evidence = normalize_ccm_artifact(client_policy_artifact(), text); + + assert_eq!(errors, 0); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].format, LogFormat::Ccm); + assert_eq!(entries[0].timezone_offset, Some(0)); + assert_eq!(evidence.len(), 1); + assert_eq!(evidence[0].timestamp.offset_minutes, Some(240)); + assert_eq!( + evidence[0].timestamp.ordering_state, + SccmTimeOrderingState::NormalizedUtc + ); + assert!(evidence[0].timestamp.utc_millis.is_some()); +} + +#[test] +fn microsecond_precision_tail_is_not_read_as_a_source_offset() { + // A six-digit unsigned tail is ambiguous: `%03u%d` would read it as three + // millisecond digits plus a positive offset, and .NET microsecond + // precision writes six fractional digits. 456 is not a real UTC offset + // (it is neither within UTC-14..UTC+14 as a quarter-hour value nor a + // shape `%d` emits), so the tail stays fractional and the record is not + // promoted to UTC-normalized ordering. + let text = r#""#; + let (entries, errors) = + cmtraceopen_parser::parser::ccm::parse_content(text, "PolicyAgent.log", None); + let evidence = normalize_ccm_artifact(client_policy_artifact(), text); + + assert_eq!(errors, 0); + assert_eq!(evidence.len(), 1); + assert_eq!( + evidence[0].timestamp.original_display.as_deref(), + Some("07-30-2026 10:00:00.123456") + ); + assert_eq!(evidence[0].timestamp.offset_minutes, None); + assert_eq!( + evidence[0].timestamp.ordering_state, + SccmTimeOrderingState::OffsetMissing + ); + assert_eq!(evidence[0].timestamp.utc_millis, None); + + // The public LogEntry still carries the pre-spine greedy projection, + // which assigns the final digit to the offset. That divergence is + // deliberate compatibility, not a second reading of the grammar. + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].timezone_offset, Some(6)); +} + +#[test] +fn evidence_uses_one_logical_record_and_normalized_utc_ordering() { + let text = include_str!("fixtures/sccm/spine/multiline-policy.log"); + let evidence = normalize_ccm_artifact(client_policy_artifact(), text); + + assert_eq!(evidence.len(), 1); + assert_eq!(evidence[0].evidence_id, "client-policy-agent:1-2"); + assert_eq!(evidence[0].reference.entry_id, "client-policy-agent:1-2"); + assert_eq!(evidence[0].reference.artifact_id, "client-policy-agent"); + assert_eq!(evidence[0].reference.line_start, Some(1)); + assert_eq!(evidence[0].reference.line_end, Some(2)); + assert_eq!( + evidence[0].ccm_source_file.as_deref(), + Some("policyagent.cpp") + ); + assert_eq!( + evidence[0].timestamp.original_display.as_deref(), + Some("07-30-2026 10:00:00.000") + ); + assert_eq!(evidence[0].timestamp.offset_minutes, Some(-240)); + assert_eq!( + evidence[0].timestamp.ordering_state, + SccmTimeOrderingState::NormalizedUtc + ); + assert!(evidence[0].timestamp.utc_millis.is_some()); +} + +#[test] +fn evidence_missing_or_invalid_time_provenance_is_not_comparable() { + let cases = [ + ( + r#""#, + SccmTimeOrderingState::OffsetMissing, + None, + ), + ( + r#""#, + SccmTimeOrderingState::OffsetInvalid, + Some(99999), + ), + ( + r#""#, + SccmTimeOrderingState::TimestampMissing, + Some(-240), + ), + ]; + + for (text, expected_state, expected_offset) in cases { + let evidence = normalize_ccm_artifact(client_policy_artifact(), text); + assert_eq!(evidence.len(), 1, "{expected_state:?}"); + assert_eq!( + evidence[0].timestamp.ordering_state, expected_state, + "{text}" + ); + assert_eq!(evidence[0].timestamp.offset_minutes, expected_offset); + assert_eq!(evidence[0].timestamp.utc_millis, None); + } +} + +#[test] +fn evidence_export_is_deterministic_redacted_and_non_mutating() { + let text = include_str!("fixtures/sccm/spine/multiline-policy.log"); + let first = normalize_ccm_artifact(client_policy_artifact(), text); + let before_export = first.clone(); + let first_json = serde_json::to_string(&first).unwrap(); + let second = normalize_ccm_artifact(client_policy_artifact(), text); + + assert_eq!(first, before_export); + assert_eq!(first, second); + assert_public_json_omits(&first_json, r"NT AUTHORITY\SYSTEM"); + assert_public_json_omits(&first_json, r"C:\Windows\CCM\Logs"); + assert_eq!( + first[0].execution_context, None, + "public export omits unkeyed context handles by default" + ); + + let alternate = r#""#; + let alternate_evidence = normalize_ccm_artifact(client_policy_artifact(), alternate); + let alternate_json = serde_json::to_string(&alternate_evidence).unwrap(); + assert_public_json_omits(&alternate_json, r"LAB\SyntheticUser"); + assert_eq!(alternate_evidence[0].execution_context, None); +} + +#[test] +fn public_json_sensitive_assertion_detects_serde_escaped_backslashes() { + let leaked = serde_json::json!([{"message": r"LAB\SyntheticUser"}]); + let json = serde_json::to_string(&leaked).unwrap(); + + assert!(json.contains(r"LAB\\SyntheticUser")); + assert!(public_json_contains_sensitive(&json, r"LAB\SyntheticUser")); +} + +#[test] +fn evidence_public_message_projection_redacts_sensitive_markers_and_preserves_safe_tokens() { + let assignment_id = "{ABCDEFAB-0000-0000-0000-000000000001}"; + let text = format!( + r#""# + ); + + let first = normalize_ccm_artifact(client_policy_artifact(), &text); + let second = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &first[0].message; + let json = serde_json::to_string(&first).unwrap(); + + assert_eq!(first, second); + assert!(message.starts_with("[sccm-public-message-v1] ")); + assert!(message.contains(assignment_id)); + assert!(message.contains("hr=0x80070005")); + for sensitive in [ + r"LAB\SyntheticUser", + "synthetic credential with spaces", + "synthetic-secret", + ] { + assert!( + !message.contains(sensitive), + "{sensitive} leaked in message" + ); + assert_public_json_omits(&json, sensitive); + } + assert!(message.contains("[redacted:sccm-public-message-v1]")); +} + +#[test] +fn evidence_public_message_projection_redacts_provider_handles_in_all_positions() { + let cases = [ + ( + "QueryHandle=SELECT * FROM SMS_R_System; status=71", + "SELECT * FROM SMS_R_System", + Some("status=71"), + ), + ( + "phase=receive; QueryHandle:/AdminService/v1.0/device; status=72", + "/AdminService/v1.0/device", + Some("status=72"), + ), + ( + r#"phase=receive; status=73; "QueryHandle"="opaque private query""#, + "opaque private query", + None, + ), + ( + "CallerHandle=opaque-private-caller; status=74", + "opaque-private-caller", + Some("status=74"), + ), + ( + r#"phase=receive; CallerHandle:"opaque private caller"; status=75"#, + "opaque private caller", + Some("status=75"), + ), + ( + r#"phase=receive; status=76; "CallerHandle"="private-caller-tail""#, + "private-caller-tail", + None, + ), + ( + "Authorization=Bearer private-auth-start; status=77", + "private-auth-start", + Some("status=77"), + ), + ( + "phase=receive; Authorization: Custom private-auth-middle; status=78", + "private-auth-middle", + Some("status=78"), + ), + ( + r#"phase=receive; status=79; "Authorization"="private-auth-tail""#, + "private-auth-tail", + None, + ), + ( + "AuthorizationHeader=Custom private-credential-value; status=81", + "private-credential-value", + Some("status=81"), + ), + ( + "AuthorizationToken=private-auth-token-value; status=82", + "private-auth-token-value", + Some("status=82"), + ), + ( + "QueryHandle=SELECT 1; DROP TABLE private_object; status=80", + "DROP TABLE private_object", + Some("status=80"), + ), + ]; + + for (raw_message, sensitive, safe_tail) in cases { + let text = format!( + r#""# + ); + let evidence = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &evidence[0].message; + let json = serde_json::to_string(&evidence).unwrap(); + + assert!( + !message.contains(sensitive), + "{sensitive} leaked from {raw_message}" + ); + assert_public_json_omits(&json, sensitive); + assert!( + message.contains("[redacted:sccm-public-message-v1]"), + "{raw_message} was not classified as sensitive" + ); + if let Some(safe_tail) = safe_tail { + assert!( + message.contains(safe_tail), + "{safe_tail} was swallowed for {raw_message}" + ); + } + } +} + +#[test] +fn evidence_public_message_projection_fails_closed_without_path_or_code_false_positives() { + let assignment_id = "{ABCDEFAB-0000-0000-0000-000000000001}"; + let text = format!( + r#""# + ); + + let evidence = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &evidence[0].message; + + assert!(message.starts_with("[sccm-public-message-v1] ")); + assert!(message.contains(r"C:\Windows\CCM\Logs\PolicyAgent.log")); + assert!(message.contains(assignment_id)); + assert!(message.contains("status=71")); + for sensitive in [ + r"LAB\SyntheticUser", + "synthetic-bearer", + "leaked-credential-fragment", + "synthetic-signature", + "synthetic-client-token", + ] { + assert!(!message.contains(sensitive), "{sensitive} leaked"); + } +} + +#[test] +fn evidence_public_message_projection_redacts_unterminated_sensitive_values_to_end() { + let assignment_id = "{ABCDEFAB-0000-0000-0000-000000000001}"; + let text = format!( + r#""# + ); + + let evidence = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &evidence[0].message; + + assert!(message.starts_with("[sccm-public-message-v1] ")); + assert!(message.contains(assignment_id)); + assert!(message.contains("hr=0x80070005")); + assert!(message.ends_with("[redacted:sccm-public-message-v1]")); + assert!(!message.contains("leaked-unterminated-tail")); +} + +#[test] +fn evidence_public_message_projection_redacts_whitespace_delimited_credentials() { + let cases = [ + ( + "Authorization Bearer synthetic-auth-token", + "synthetic-auth-token", + ), + ( + r#"Authorization Bearer "synthetic quoted auth token""#, + "synthetic quoted auth token", + ), + ( + "Bearer synthetic-standalone-token", + "synthetic-standalone-token", + ), + ( + "Bearer 'synthetic quoted bearer token'", + "synthetic quoted bearer token", + ), + ( + "client_secret synthetic-client-secret", + "synthetic-client-secret", + ), + ( + r#"client_secret "synthetic quoted client secret""#, + "synthetic quoted client secret", + ), + ("sig synthetic-signature", "synthetic-signature"), + ( + "clientToken synthetic-client-token", + "synthetic-client-token", + ), + ("credential synthetic-credential", "synthetic-credential"), + ( + "credential 'synthetic quoted credential'", + "synthetic quoted credential", + ), + ]; + + for (raw_message, sensitive) in cases { + let text = format!( + r#""# + ); + let evidence = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &evidence[0].message; + let json = serde_json::to_string(&evidence).unwrap(); + + assert!( + !message.contains(sensitive), + "{sensitive} leaked in projected message for {raw_message}" + ); + assert_public_json_omits(&json, sensitive); + assert!( + message.contains("[redacted:sccm-public-message-v1]"), + "{raw_message} was not classified as sensitive" + ); + } +} + +#[test] +fn evidence_public_message_projection_redacts_quoted_structured_keys() { + let cases = [ + ( + r#"payload={"token":"synthetic-json-token","user":"SyntheticJsonUser"} status=71"#, + ["synthetic-json-token", "SyntheticJsonUser"], + "status=71", + ), + ( + "payload={'password':'synthetic-json-password','user':'SyntheticSingleUser'} hr=0x80070005", + ["synthetic-json-password", "SyntheticSingleUser"], + "hr=0x80070005", + ), + ]; + + for (raw_message, sensitive_values, safe) in cases { + let text = format!( + r#""# + ); + let evidence = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &evidence[0].message; + let json = serde_json::to_string(&evidence).unwrap(); + + assert!(message.contains(safe), "{safe} was swallowed"); + for sensitive in sensitive_values { + assert!(!message.contains(sensitive), "{sensitive} leaked"); + assert_public_json_omits(&json, sensitive); + } + } +} + +#[test] +fn evidence_public_message_projection_bounds_query_values_at_ampersand() { + let text = r#""#; + + let evidence = normalize_ccm_artifact(client_policy_artifact(), text); + let message = &evidence[0].message; + let json = serde_json::to_string(&evidence).unwrap(); + + assert!(!message.contains("synthetic-query-token")); + assert_public_json_omits(&json, "synthetic-query-token"); + assert!(message.contains("&status=71")); +} + +#[test] +fn evidence_public_message_projection_fails_closed_for_unterminated_whitespace_values() { + let cases = [ + ( + r#"Authorization Bearer "synthetic-unterminated-auth"#, + "synthetic-unterminated-auth", + ), + ( + "Bearer 'synthetic-unterminated-bearer", + "synthetic-unterminated-bearer", + ), + ( + r#"client_secret "synthetic-unterminated-client-secret"#, + "synthetic-unterminated-client-secret", + ), + ( + "credential 'synthetic-unterminated-credential", + "synthetic-unterminated-credential", + ), + ]; + + for (raw_message, sensitive) in cases { + let text = format!( + r#""# + ); + let evidence = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &evidence[0].message; + + assert!(message.contains("hr=0x80070005"), "{raw_message}"); + assert!( + message.ends_with("[redacted:sccm-public-message-v1]"), + "{raw_message}" + ); + assert!(!message.contains(sensitive), "{sensitive} leaked"); + } +} + +#[test] +fn evidence_public_message_projection_bounds_unquoted_values_before_safe_evidence() { + let assignment_id = "{ABCDEFAB-0000-0000-0000-000000000001}"; + let text = format!( + r#""# + ); + + let evidence = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &evidence[0].message; + + for sensitive in [ + "synthetic-client-secret", + "synthetic-signature", + "synthetic-client-token", + "synthetic-credential", + ] { + assert!(!message.contains(sensitive), "{sensitive} leaked"); + } + for safe in [ + "hr=0x80070005", + assignment_id, + r"C:\Windows\CCM\Logs\PolicyAgent.log", + "status=71", + ] { + assert!(message.contains(safe), "{safe} was swallowed"); + } +} + +#[test] +fn evidence_public_message_projection_redacts_local_and_upn_identities_without_path_noise() { + let text = r#""#; + + let evidence = normalize_ccm_artifact(client_policy_artifact(), text); + let message = &evidence[0].message; + + for sensitive in [r".\LocalUser", "Synthetic.User@contoso.example"] { + assert!(!message.contains(sensitive), "{sensitive} leaked"); + } + for safe in [ + "package@1.2.3", + r"C:\Windows\CCM\Logs\PolicyAgent.log", + r".\Cache\Policy.bin", + r"\\LAB-CM01\SMS_CCM\Logs\MP.log", + ] { + assert!(message.contains(safe), "{safe} was falsely redacted"); + } +} + +#[test] +fn evidence_public_message_projection_redacts_colon_delimited_windows_identities() { + for sensitive in [r"LAB\SyntheticUser", r".\LocalUser"] { + let raw_message = format!( + r#"Caller:{sensitive}; Path C:\Windows\CCM\Logs\PolicyAgent.log; Relative .\Cache\Policy.bin; UNC \\LAB-CM01\SMS_CCM\Logs\MP.log; status=71"# + ); + let text = format!( + r#""# + ); + + let evidence = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &evidence[0].message; + let json = serde_json::to_string(&evidence).unwrap(); + + assert!(!message.contains(sensitive), "{sensitive} leaked"); + assert_public_json_omits(&json, sensitive); + for safe in [ + r"C:\Windows\CCM\Logs\PolicyAgent.log", + r".\Cache\Policy.bin", + r"\\LAB-CM01\SMS_CCM\Logs\MP.log", + "status=71", + ] { + assert!(message.contains(safe), "{safe} was falsely redacted"); + } + } +} + +#[test] +fn evidence_public_message_projection_redacts_path_adjacent_windows_identities() { + let cases: [(&str, &[&str]); 3] = [ + ( + r"Caller LAB\SyntheticUser\subdirectory", + &["Caller", "subdirectory"], + ), + ( + r"Profile C:\Users\LAB\SyntheticUser\profile.dat", + &[r"C:\Users\", "profile.dat"], + ), + ( + r"Home \\server\users\LAB\SyntheticUser\cache", + &[r"\\server\users\", "cache"], + ), + ]; + let mut violations = Vec::new(); + + for (raw_message, safe_fragments) in cases { + let text = format!( + r#""# + ); + let first = normalize_ccm_artifact(client_policy_artifact(), &text); + let second = normalize_ccm_artifact(client_policy_artifact(), &text); + let message = &first[0].message; + let json = serde_json::to_string(&first).unwrap(); + + assert_eq!(first, second, "{raw_message}"); + if message.contains(r"LAB\SyntheticUser") + || public_json_contains_sensitive(&json, r"LAB\SyntheticUser") + { + violations.push(format!("identity leaked: {raw_message}")); + } + if !message.contains("[redacted:sccm-public-message-v1]") { + violations.push(format!("identity was not classified: {raw_message}")); + } + for safe in safe_fragments { + if !message.contains(safe) { + violations.push(format!("{safe} was swallowed: {raw_message}")); + } + } + } + + assert!( + violations.is_empty(), + "path-adjacent identity projection violations: {violations:#?}" + ); +} + +#[test] +fn evidence_public_projection_redacts_identity_on_every_string_surface() { + let raw_message = r#"Profile C:\Profiles\LAB\SyntheticUser\profile.dat; Home \\server\home\LAB\SyntheticHomeUser\cache; Local C:\Profiles\.\LocalUser\profile.dat; account={"domain":"LAB","accountName":"SyntheticJsonUser"}; sam={"domain":"LAB","samAccountName":"SyntheticSamUser"}; localUser=LocalStructuredUser; status=71"#; + let text = format!( + r#""# + ); + let first = normalize_ccm_artifact(client_policy_artifact(), &text); + let second = normalize_ccm_artifact(client_policy_artifact(), &text); + let json = serde_json::to_string(&first).unwrap(); + let evidence = &first[0]; + + assert_eq!(first, second); + for sensitive in [ + "SyntheticUser", + "SyntheticHomeUser", + "LocalUser", + "SyntheticJsonUser", + "SyntheticSamUser", + "LocalStructuredUser", + "ComponentUser", + "FileUser", + ] { + assert_public_json_omits(&json, sensitive); + } + assert!(evidence.message.contains("status=71")); + assert!(evidence + .message + .contains("[redacted:sccm-public-message-v1]")); + assert!( + evidence + .component + .as_deref() + .is_some_and(|value| value.contains("[redacted:sccm-public-message-v1]")), + "component identity was not classified" + ); + assert!( + evidence + .ccm_source_file + .as_deref() + .is_some_and(|value| value.contains("[redacted:sccm-public-message-v1]")), + "CCM source-file identity was not classified" + ); +} + +#[test] +fn serde_roles_are_string_backed_and_future_tolerant() { + assert_eq!( + serde_json::to_string(&SccmRole::ManagementPoint).unwrap(), + r#""managementPoint""# + ); + assert_eq!( + serde_json::to_string(&SccmRole::Unknown("futureEdgeRole".into())).unwrap(), + r#""futureEdgeRole""# + ); + assert_eq!( + serde_json::from_str::(r#""futureEdgeRole""#).unwrap(), + SccmRole::Unknown("futureEdgeRole".into()) + ); + + let admin_service = serde_json::from_str::(r#""adminService""#).unwrap(); + assert_eq!( + serde_json::to_string(&admin_service).unwrap(), + r#""adminService""# + ); +} + +#[test] +fn serde_families_are_string_backed_and_future_tolerant() { + assert_eq!( + serde_json::to_string(&SccmArtifactFamily::ClientPolicy).unwrap(), + r#""clientPolicy""# + ); + assert_eq!( + serde_json::to_string(&SccmArtifactFamily::Unknown("futureFamily".into())).unwrap(), + r#""futureFamily""# + ); + assert_eq!( + serde_json::from_str::(r#""futureFamily""#).unwrap(), + SccmArtifactFamily::Unknown("futureFamily".into()) + ); +} + +#[test] +fn serde_rotations_have_exact_tags_and_preserve_future_values() { + let known = [ + (SccmRotation::Current, r#"{"kind":"current"}"#), + (SccmRotation::LoUnderscore, r#"{"kind":"loUnderscore"}"#), + ( + SccmRotation::Numbered(3), + r#"{"kind":"numbered","value":3}"#, + ), + ( + SccmRotation::Timestamped("20260730-150000".into()), + r#"{"kind":"timestamped","value":"20260730-150000"}"#, + ), + ]; + + for (rotation, expected) in known { + assert_eq!(serde_json::to_string(&rotation).unwrap(), expected); + assert_eq!( + serde_json::from_str::(expected).unwrap(), + rotation + ); + } + + let future = r#"{"kind":"vendorArchive","value":{"lineage":"A7","sequence":4}}"#; + let rotation = serde_json::from_str::(future).unwrap(); + let unknown: &SccmUnknownRotation = match &rotation { + SccmRotation::Unknown(unknown) => unknown, + other => panic!("future rotation did not remain unknown: {other:?}"), + }; + assert_eq!(unknown.kind, "vendorArchive"); + assert_eq!( + unknown.value, + Some(serde_json::json!({"lineage": "A7", "sequence": 4})) + ); + assert_eq!(serde_json::to_string(&rotation).unwrap(), future); + + let valueless_future = r#"{"kind":"vendorArchiveWithoutValue"}"#; + let rotation = serde_json::from_str::(valueless_future).unwrap(); + assert_eq!(serde_json::to_string(&rotation).unwrap(), valueless_future); +} + +#[test] +fn serde_known_rotation_tags_reject_malformed_shapes() { + for malformed in [ + r#"{"kind":"current","value":null}"#, + r#"{"kind":"loUnderscore","value":"unexpected"}"#, + r#"{"kind":"numbered"}"#, + r#"{"kind":"numbered","value":-1}"#, + r#"{"kind":"numbered","value":4294967296}"#, + r#"{"kind":"timestamped"}"#, + r#"{"kind":"timestamped","value":3}"#, + r#"{"kind":"current","unexpected":true}"#, + ] { + assert!( + serde_json::from_str::(malformed).is_err(), + "accepted malformed known rotation: {malformed}" + ); + } +} + +#[test] +fn serde_known_rotation_values_reject_noncanonical_values() { + for noncanonical in [ + r#"{"kind":"numbered","value":0}"#, + r#"{"kind":"timestamped","value":""}"#, + r#"{"kind":"timestamped","value":"20260730-15000"}"#, + r#"{"kind":"timestamped","value":"20260730-1500000"}"#, + r#"{"kind":"timestamped","value":"2026073A-150000"}"#, + r#"{"kind":"timestamped","value":"20260730_150000"}"#, + r#"{"kind":"timestamped","value":"20260229-150000"}"#, + r#"{"kind":"timestamped","value":"20260730-240000"}"#, + r#"{"kind":"timestamped","value":"20260730-156000"}"#, + r#"{"kind":"timestamped","value":"20260730-150060"}"#, + r#"{"kind":"timestamped","value":"20260730-150000Z"}"#, + ] { + assert!( + serde_json::from_str::(noncanonical).is_err(), + "accepted noncanonical known rotation: {noncanonical}" + ); + } +} + +#[test] +fn serde_known_rotation_values_fail_closed_on_serialize() { + for rotation in [ + SccmRotation::Numbered(0), + SccmRotation::Timestamped("20260730_150000".into()), + SccmRotation::Timestamped("20260229-150000".into()), + SccmRotation::Timestamped("20260730-150060".into()), + ] { + assert!( + serde_json::to_string(&rotation).is_err(), + "serialized noncanonical known rotation: {rotation:?}" + ); + } +} + +#[test] +fn serde_canonical_rotation_values_round_trip() { + for rotation in [ + SccmRotation::Numbered(1), + SccmRotation::Numbered(u32::MAX), + SccmRotation::Timestamped("20240229-000000".into()), + SccmRotation::Timestamped("20261231-235959".into()), + ] { + let json = serde_json::to_string(&rotation).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + rotation + ); + } +} + +#[test] +fn artifact_round_trip_preserves_capture_and_rotation_provenance() { + let artifact = SccmArtifact { + artifact_id: "client-content-transfer".into(), + display_name: "ContentTransferManager.log.2".into(), + original_path: Some(r"C:\Windows\CCM\Logs\ContentTransferManager.log.2".into()), + host: Some("LAB-CLIENT-01".into()), + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1007".into()), + collected_at_utc: Some("2026-07-30T15:00:00Z".into()), + rotation: SccmRotation::Numbered(2), + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".into()), + }; + + let json = serde_json::to_value(&artifact).unwrap(); + assert_eq!(json["rotation"]["kind"], "numbered"); + assert_eq!(json["rotation"]["value"], 2); + assert_eq!(json["coverage"], "captured"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + artifact + ); +} + +#[test] +fn coverage_json_names_are_exact_and_never_collapse_to_captured() { + for (state, expected) in [ + (SccmCoverageState::Captured, r#""captured""#), + (SccmCoverageState::Absent, r#""absent""#), + (SccmCoverageState::AccessDenied, r#""accessDenied""#), + (SccmCoverageState::Capped, r#""capped""#), + (SccmCoverageState::Skipped, r#""skipped""#), + (SccmCoverageState::Unsupported, r#""unsupported""#), + (SccmCoverageState::ParseFailed, r#""parseFailed""#), + ] { + assert_eq!(serde_json::to_string(&state).unwrap(), expected); + let round_trip = serde_json::from_str::(expected).unwrap(); + assert_eq!(round_trip, state); + if expected != r#""captured""# { + assert_ne!(round_trip, SccmCoverageState::Captured); + } + } +} + +#[test] +fn artifact_manifest_fixture_preserves_each_coverage_state() { + let artifacts: Vec = + serde_json::from_str(include_str!("fixtures/sccm/spine/artifact-manifest.json")).unwrap(); + + assert_eq!(artifacts.len(), 4); + assert_eq!(artifacts[0].rotation, SccmRotation::Current); + assert_eq!(artifacts[1].rotation, SccmRotation::Numbered(2)); + assert_eq!( + artifacts + .iter() + .map(|artifact| artifact.coverage.clone()) + .collect::>(), + vec![ + SccmCoverageState::Captured, + SccmCoverageState::Captured, + SccmCoverageState::Absent, + SccmCoverageState::AccessDenied, + ] + ); + assert_eq!( + artifacts[0].original_path.as_deref(), + Some(r"C:\Windows\CCM\Logs\PolicyAgent.log") + ); + assert_eq!(artifacts[3].encoding, None); +} + +#[test] +fn catalog_classifies_client_policy_without_changing_ccm_parser_kind() { + let class = classify_artifact_name("PolicyAgent.log", SccmRole::Client); + assert_eq!(class.family, SccmArtifactFamily::ClientPolicy); + assert_eq!(class.logical_name, "policyAgent"); + assert!(class.uses_ccm_records); + + let ccm = r#""#; + assert_eq!( + detect_parser("PolicyAgent.log", ccm).parser, + ParserKind::Ccm + ); +} + +#[test] +fn catalog_recognizes_rotated_client_log_by_base_name() { + let class = classify_artifact_name("AppEnforce.log.3", SccmRole::Client); + assert_eq!(class.family, SccmArtifactFamily::ClientApplication); + assert_eq!(class.rotation, SccmRotation::Numbered(3)); +} + +#[test] +fn catalog_recognizes_standard_lo_rollback_name_by_canonical_log_basename() { + let class = classify_artifact_name("CcmExec.lo_", SccmRole::Client); + + assert_eq!(class.basename, "CcmExec.log"); + assert_eq!(class.logical_name, "ccmExec"); + assert_eq!(class.family, SccmArtifactFamily::ClientHealth); + assert_eq!(class.rotation, SccmRotation::LoUnderscore); + assert!(class.uses_ccm_records); + assert!(class.supported_for_diagnosis); +} + +#[test] +fn catalog_leaves_unrecognized_sources_explicitly_unknown() { + let class = classify_artifact_name("CustomVendorHook.log", SccmRole::Client); + assert_eq!( + class.family, + SccmArtifactFamily::Unknown("customVendorHook".into()) + ); + assert!(!class.supported_for_diagnosis); +} + +#[test] +fn catalog_exact_declared_tuples_match_the_public_classifier() { + let expected = expected_catalog_tuples(); + let declared = declared_source_catalog(); + assert_eq!(declared.len(), expected.len()); + + for (entry, expected) in declared.iter().zip(expected.iter()) { + assert_eq!(entry.basename, expected.0); + assert_eq!(entry.role, expected.1); + assert_eq!(entry.logical_name, expected.2); + assert_eq!(entry.family, expected.3); + assert_eq!(entry.uses_ccm_records, expected.4); + assert_eq!(entry.supported_for_diagnosis, expected.5); + assert_eq!(entry.rotation, SccmRotation::Current); + + let classified = classify_artifact_name(expected.0, expected.1.clone()); + assert_eq!(classified, *entry); + } +} + +#[test] +fn catalog_declared_basename_role_tuples_are_unique() { + let mut keys = std::collections::BTreeSet::new(); + for entry in declared_source_catalog() { + let role = serde_json::to_string(&entry.role).unwrap(); + assert!( + keys.insert((entry.basename.to_ascii_lowercase(), role)), + "duplicate catalog tuple: {} / {:?}", + entry.basename, + entry.role + ); + } +} + +#[test] +fn catalog_rejects_every_role_not_declared_by_the_exact_table() { + let expected = expected_catalog_tuples(); + let basenames = expected + .iter() + .map(|entry| entry.0) + .collect::>(); + + for basename in basenames { + let allowed_roles = expected + .iter() + .filter(|entry| entry.0 == basename) + .map(|entry| &entry.1) + .collect::>(); + for role in known_roles() { + if allowed_roles.contains(&&role) { + continue; + } + + let class = classify_artifact_name(basename, role.clone()); + assert_eq!(class.role, role, "{basename}"); + assert!( + matches!(class.family, SccmArtifactFamily::Unknown(_)), + "{basename} accepted undeclared role {:?}", + class.role + ); + assert!(!class.supported_for_diagnosis, "{basename}"); + } + } +} + +#[test] +fn catalog_rotation_grammar_accepts_only_canonical_suffixes() { + let canonical = [ + ("AppEnforce.log", SccmRotation::Current), + ("AppEnforce.lo_", SccmRotation::LoUnderscore), + ("AppEnforce.LO_", SccmRotation::LoUnderscore), + ("AppEnforce.log.3", SccmRotation::Numbered(3)), + ( + "AppEnforce.log.20260730-150000", + SccmRotation::Timestamped("20260730-150000".into()), + ), + ]; + for (name, expected_rotation) in canonical { + let class = classify_artifact_name(name, SccmRole::Client); + assert_eq!( + class.family, + SccmArtifactFamily::ClientApplication, + "{name}" + ); + assert_eq!(class.rotation, expected_rotation, "{name}"); + assert!(class.uses_ccm_records, "{name}"); + assert!(class.supported_for_diagnosis, "{name}"); + } + + let rejected = [ + ("AppEnforce.log.lo_", ".lo_"), + ("AppEnforce.LOG.LO_", ".LO_"), + ("AppEnforce.log.0", ".0"), + ("AppEnforce.log.03", ".03"), + ("AppEnforce.log.4294967296", ".4294967296"), + ("AppEnforce.log.backup", ".backup"), + ("AppEnforce.log.20260730_150000", ".20260730_150000"), + ("AppEnforce.log.20260229-150000", ".20260229-150000"), + ("AppEnforce.log.20260730-240000", ".20260730-240000"), + ("AppEnforce.log.20260730-150000Z", ".20260730-150000Z"), + ("AppEnforce.log.20261340-996099", ".20261340-996099"), + ]; + for (name, raw_suffix) in rejected { + let class = classify_artifact_name(name, SccmRole::Client); + assert_eq!( + class.family, + SccmArtifactFamily::ClientApplication, + "{name}" + ); + assert_eq!(class.logical_name, "appEnforce", "{name}"); + assert_eq!( + serde_json::to_value(&class.rotation).unwrap(), + serde_json::json!({"kind": "filenameSuffix", "value": raw_suffix}), + "{name}" + ); + assert!(class.uses_ccm_records, "{name}"); + assert!(!class.supported_for_diagnosis, "{name}"); + } +} + +#[test] +fn catalog_rotation_grammar_preserves_unknown_suffix_and_initialism() { + let class = classify_artifact_name("SMSVendorHook.log.archive", SccmRole::Client); + assert_eq!(class.logical_name, "smsVendorHook"); + assert_eq!( + class.family, + SccmArtifactFamily::Unknown("smsVendorHook".into()) + ); + assert_eq!( + serde_json::to_value(&class.rotation).unwrap(), + serde_json::json!({"kind": "filenameSuffix", "value": ".archive"}) + ); + assert!(!class.uses_ccm_records); + assert!(!class.supported_for_diagnosis); +} + +#[test] +fn catalog_requires_exact_producer_roles_for_server_workflow_sources() { + let cases = [ + ( + "distmgr.log", + SccmRole::SiteServer, + SccmArtifactFamily::DistributionPoint, + ), + ( + "PkgXferMgr.log", + SccmRole::SiteServer, + SccmArtifactFamily::DistributionPoint, + ), + ( + "SMSDPProv.log", + SccmRole::DistributionPoint, + SccmArtifactFamily::DistributionPoint, + ), + ( + "SMSdpmon.log", + SccmRole::DistributionPoint, + SccmArtifactFamily::DistributionPoint, + ), + ( + "PullDP.log", + SccmRole::DistributionPoint, + SccmArtifactFamily::DistributionPoint, + ), + ( + "WCM.log", + SccmRole::SiteServer, + SccmArtifactFamily::SoftwareUpdatePoint, + ), + ( + "wsyncmgr.log", + SccmRole::SiteServer, + SccmArtifactFamily::SoftwareUpdatePoint, + ), + ( + "WSUSCtrl.log", + SccmRole::SoftwareUpdatePoint, + SccmArtifactFamily::SoftwareUpdatePoint, + ), + ( + "SUPSetup.log", + SccmRole::SoftwareUpdatePoint, + SccmArtifactFamily::SoftwareUpdatePoint, + ), + ( + "AdminService.log", + SccmRole::AdminService, + SccmArtifactFamily::AdminService, + ), + ]; + + for (source, producer_role, family) in cases { + let class = classify_artifact_name(source, producer_role.clone()); + assert_eq!(class.role, producer_role, "{source}"); + assert_eq!(class.family, family, "{source}"); + assert!(class.uses_ccm_records, "{source}"); + assert!(class.supported_for_diagnosis, "{source}"); + + for role in &known_roles() { + if *role == producer_role { + continue; + } + + let class = classify_artifact_name(source, role.clone()); + assert_eq!(class.role, *role, "{source}"); + assert!( + matches!(class.family, SccmArtifactFamily::Unknown(_)), + "{source} accepted non-producer role {role:?}" + ); + assert!(!class.uses_ccm_records, "{source} / {role:?}"); + assert!(!class.supported_for_diagnosis, "{source} / {role:?}"); + } + } +} + +fn known_roles() -> [SccmRole; 8] { + [ + SccmRole::Client, + SccmRole::SiteServer, + SccmRole::ManagementPoint, + SccmRole::DistributionPoint, + SccmRole::SoftwareUpdatePoint, + SccmRole::WsUs, + SccmRole::Provider, + SccmRole::AdminService, + ] +} + +type ExpectedCatalogTuple = ( + &'static str, + SccmRole, + &'static str, + SccmArtifactFamily, + bool, + bool, +); + +fn expected_catalog_tuples() -> Vec { + vec![ + ( + "ccmsetup.log", + SccmRole::Client, + "ccmSetup", + SccmArtifactFamily::ClientSetup, + true, + true, + ), + ( + "client.msi.log", + SccmRole::Client, + "clientMsi", + SccmArtifactFamily::ClientSetup, + false, + true, + ), + ( + "CcmEval.log", + SccmRole::Client, + "ccmEval", + SccmArtifactFamily::ClientHealth, + true, + true, + ), + ( + "CcmExec.log", + SccmRole::Client, + "ccmExec", + SccmArtifactFamily::ClientHealth, + true, + true, + ), + ( + "CcmRestart.log", + SccmRole::Client, + "ccmRestart", + SccmArtifactFamily::ClientHealth, + true, + true, + ), + ( + "ClientIDManagerStartup.log", + SccmRole::Client, + "clientIdManagerStartup", + SccmArtifactFamily::ClientIdentity, + true, + true, + ), + ( + "ClientLocation.log", + SccmRole::Client, + "clientLocation", + SccmArtifactFamily::ClientLocation, + true, + true, + ), + ( + "LocationServices.log", + SccmRole::Client, + "locationServices", + SccmArtifactFamily::ClientLocation, + true, + true, + ), + ( + "CcmMessaging.log", + SccmRole::Client, + "ccmMessaging", + SccmArtifactFamily::ClientLocation, + true, + true, + ), + ( + "PolicyAgent.log", + SccmRole::Client, + "policyAgent", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "PolicyAgentProvider.log", + SccmRole::Client, + "policyAgentProvider", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "PolicyEvaluator.log", + SccmRole::Client, + "policyEvaluator", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "CIAgent.log", + SccmRole::Client, + "ciAgent", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "CIDownloader.log", + SccmRole::Client, + "ciDownloader", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "StateMessage.log", + SccmRole::Client, + "stateMessage", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "StatusAgent.log", + SccmRole::Client, + "statusAgent", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "Scheduler.log", + SccmRole::Client, + "scheduler", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "CAS.log", + SccmRole::Client, + "cas", + SccmArtifactFamily::ClientContent, + true, + true, + ), + ( + "ContentTransferManager.log", + SccmRole::Client, + "contentTransferManager", + SccmArtifactFamily::ClientContent, + true, + true, + ), + ( + "DataTransferService.log", + SccmRole::Client, + "dataTransferService", + SccmArtifactFamily::ClientContent, + true, + true, + ), + ( + "AppIntentEval.log", + SccmRole::Client, + "appIntentEval", + SccmArtifactFamily::ClientApplication, + true, + true, + ), + ( + "AppDiscovery.log", + SccmRole::Client, + "appDiscovery", + SccmArtifactFamily::ClientApplication, + true, + true, + ), + ( + "AppEnforce.log", + SccmRole::Client, + "appEnforce", + SccmArtifactFamily::ClientApplication, + true, + true, + ), + ( + "ExecMgr.log", + SccmRole::Client, + "execMgr", + SccmArtifactFamily::ClientApplication, + true, + true, + ), + ( + "ScanAgent.log", + SccmRole::Client, + "scanAgent", + SccmArtifactFamily::ClientUpdates, + true, + true, + ), + ( + "WUAHandler.log", + SccmRole::Client, + "wuaHandler", + SccmArtifactFamily::ClientUpdates, + true, + true, + ), + ( + "UpdatesDeployment.log", + SccmRole::Client, + "updatesDeployment", + SccmArtifactFamily::ClientUpdates, + true, + true, + ), + ( + "UpdatesHandler.log", + SccmRole::Client, + "updatesHandler", + SccmArtifactFamily::ClientUpdates, + true, + true, + ), + ( + "UpdatesStore.log", + SccmRole::Client, + "updatesStore", + SccmArtifactFamily::ClientUpdates, + true, + true, + ), + ( + "ServiceWindowManager.log", + SccmRole::Client, + "serviceWindowManager", + SccmArtifactFamily::ClientUpdates, + true, + true, + ), + ( + "RebootCoordinator.log", + SccmRole::Client, + "rebootCoordinator", + SccmArtifactFamily::ClientUpdates, + true, + true, + ), + ( + "CBS.log", + SccmRole::Client, + "componentBasedServicing", + SccmArtifactFamily::ClientUpdates, + false, + true, + ), + ( + "ReportingEvents.log", + SccmRole::Client, + "reportingEvents", + SccmArtifactFamily::ClientUpdates, + false, + true, + ), + ( + "smsts.log", + SccmRole::Client, + "smsts", + SccmArtifactFamily::ClientTaskSequence, + true, + true, + ), + ( + "InventoryAgent.log", + SccmRole::Client, + "inventoryAgent", + SccmArtifactFamily::ClientInventory, + true, + true, + ), + ( + "InventoryProvider.log", + SccmRole::Client, + "inventoryProvider", + SccmArtifactFamily::ClientInventory, + true, + true, + ), + ( + "InventoryAgentProvider.log", + SccmRole::Client, + "inventoryAgentProvider", + SccmArtifactFamily::ClientInventory, + true, + true, + ), + ( + "CITaskMgr.log", + SccmRole::Client, + "ciTaskMgr", + SccmArtifactFamily::ClientCompliance, + true, + true, + ), + ( + "DCMAgent.log", + SccmRole::Client, + "dcmAgent", + SccmArtifactFamily::ClientCompliance, + true, + true, + ), + ( + "DCMReporting.log", + SccmRole::Client, + "dcmReporting", + SccmArtifactFamily::ClientCompliance, + true, + true, + ), + ( + "SWMTRReportGen.log", + SccmRole::Client, + "swmtrReportGen", + SccmArtifactFamily::ClientMetering, + true, + true, + ), + ( + "sitecomp.log", + SccmRole::SiteServer, + "sitecomp", + SccmArtifactFamily::SiteComponent, + true, + true, + ), + ( + "hman.log", + SccmRole::SiteServer, + "hman", + SccmArtifactFamily::SiteComponent, + true, + true, + ), + ( + "statmgr.log", + SccmRole::SiteServer, + "statmgr", + SccmArtifactFamily::SiteStatus, + true, + true, + ), + ( + "statesys.log", + SccmRole::SiteServer, + "statesys", + SccmArtifactFamily::SiteStatus, + true, + true, + ), + ( + "MP_CliReg.log", + SccmRole::ManagementPoint, + "mpCliReg", + SccmArtifactFamily::ManagementPoint, + true, + true, + ), + ( + "MP_GetAuth.log", + SccmRole::ManagementPoint, + "mpGetAuth", + SccmArtifactFamily::ManagementPoint, + true, + true, + ), + ( + "MP_GetPolicy.log", + SccmRole::ManagementPoint, + "mpGetPolicy", + SccmArtifactFamily::ManagementPoint, + true, + true, + ), + ( + "MP_Location.log", + SccmRole::ManagementPoint, + "mpLocation", + SccmArtifactFamily::ManagementPoint, + true, + true, + ), + ( + "MP_RegistrationManager.log", + SccmRole::ManagementPoint, + "mpRegistrationManager", + SccmArtifactFamily::ManagementPoint, + true, + true, + ), + ( + "mpcontrol.log", + SccmRole::SiteServer, + "mpcontrol", + SccmArtifactFamily::ManagementPoint, + true, + true, + ), + ( + "distmgr.log", + SccmRole::SiteServer, + "distmgr", + SccmArtifactFamily::DistributionPoint, + true, + true, + ), + ( + "PkgXferMgr.log", + SccmRole::SiteServer, + "pkgXferMgr", + SccmArtifactFamily::DistributionPoint, + true, + true, + ), + ( + "SMSDPProv.log", + SccmRole::DistributionPoint, + "smsDpProv", + SccmArtifactFamily::DistributionPoint, + true, + true, + ), + ( + "SMSdpmon.log", + SccmRole::DistributionPoint, + "smsDpmon", + SccmArtifactFamily::DistributionPoint, + true, + true, + ), + ( + "PullDP.log", + SccmRole::DistributionPoint, + "pullDp", + SccmArtifactFamily::DistributionPoint, + true, + true, + ), + ( + "WCM.log", + SccmRole::SiteServer, + "wcm", + SccmArtifactFamily::SoftwareUpdatePoint, + true, + true, + ), + ( + "WSUSCtrl.log", + SccmRole::SoftwareUpdatePoint, + "wsusCtrl", + SccmArtifactFamily::SoftwareUpdatePoint, + true, + true, + ), + ( + "wsyncmgr.log", + SccmRole::SiteServer, + "wsyncmgr", + SccmArtifactFamily::SoftwareUpdatePoint, + true, + true, + ), + ( + "SUPSetup.log", + SccmRole::SoftwareUpdatePoint, + "supSetup", + SccmArtifactFamily::SoftwareUpdatePoint, + true, + true, + ), + ( + "replmgr.log", + SccmRole::SiteServer, + "replmgr", + SccmArtifactFamily::Hierarchy, + true, + true, + ), + ( + "rcmctrl.log", + SccmRole::SiteServer, + "rcmctrl", + SccmArtifactFamily::Hierarchy, + true, + true, + ), + ( + "sender.log", + SccmRole::SiteServer, + "sender", + SccmArtifactFamily::Hierarchy, + true, + true, + ), + ( + "despool.log", + SccmRole::SiteServer, + "despool", + SccmArtifactFamily::Hierarchy, + true, + true, + ), + ( + "Smsprov.log", + SccmRole::Provider, + "smsprov", + SccmArtifactFamily::Provider, + true, + true, + ), + ( + "AdminService.log", + SccmRole::AdminService, + "adminService", + SccmArtifactFamily::AdminService, + true, + true, + ), + ] +} diff --git a/docs/sccm/preparation/issue-319-client-intake.md b/docs/sccm/preparation/issue-319-client-intake.md new file mode 100644 index 000000000..bd741189c --- /dev/null +++ b/docs/sccm/preparation/issue-319-client-intake.md @@ -0,0 +1,246 @@ +# Issue #319 client intake preparation + +## Purpose and dependency boundary + +This document began as the bounded source inventory and synthetic fixture +design for issue #319. The pure parser intake is now implemented against the +published #318 artifact, coverage, rotation, and schema contracts; its public +assessment is executable and validated on both serialization and +deserialization. The native manifest reader/writer, bounded discovery/capture, +legacy adapter, and Windows acceptance described below remain pending and do +not become delivered merely because the pure projection is available. + +The proposed native adapter consumes the catalog below and writes an additive, +versioned SCCM extension (for example `sccm-manifest.json`). The generic bundle +manifest and generic `ArtifactStatus` remain unchanged: their meanings are +only `Collected`, `Missing`, and `Failed`. SCCM capture detail is an extension, +not a reinterpretation of a generic failure. + +## Bounded client source catalog + +| Catalog entry | Allowed basenames | Stable group memberships | Workflow consumer | Default requiredness | Rotations | +| --- | --- | --- | --- | --- | --- | +| `client-ccmsetup` | `ccmsetup.log`, `client.msi.log` | `client-ccmsetup` | health | incident core | current, `.lo_`, numbered, timestamped when explicitly captured | +| `client-evaluation` | `CcmEval.log`, `CcmExec.log`, `CcmRestart.log` | `client-evaluation` | health | incident core | same | +| `client-identity` | `ClientIDManagerStartup.log` | `client-identity` | health | incident core | same | +| `client-location` | `ClientLocation.log`, `CcmMessaging.log` | `client-location` | health | incident core | same | +| `client-location-services-shared` | `LocationServices.log` | `client-content`, `client-location` | health, deployment | incident core | same | +| `client-policy-agent` | `PolicyAgent.log`, `PolicyAgentProvider.log`, `PolicyEvaluator.log`, `Scheduler.log` | `client-policy-agent` | policy | policy bundle | same | +| `client-policy-state` | `CIAgent.log`, `CIDownloader.log`, `StateMessage.log`, `StatusAgent.log` | `client-policy-state` | policy | policy bundle | same | +| `client-app-intent` | `AppIntentEval.log`, `AppDiscovery.log` | `client-app-intent` | deployment | deployment bundle | same | +| `client-app-enforce` | `AppEnforce.log`, `ExecMgr.log` | `client-app-enforce` | deployment | deployment bundle | same | +| `client-content` | `CAS.log`, `ContentTransferManager.log`, `DataTransferService.log` | `client-content` | deployment | deployment bundle | same | +| `client-updates` | `ScanAgent.log`, `WUAHandler.log`, `UpdatesDeployment.log`, `UpdatesHandler.log`, `UpdatesStore.log` | `client-updates` | updates | update bundle | same | +| `client-windows-update-supplemental` | `ReportingEvents.log`, explicitly captured CBS/DISM export | `client-windows-update-supplemental` | updates | optional supplemental | declared separately only | + +`ccmsetup` is separate from operational CCM logs. A basename is eligible only +when a declared source, client role/provenance, and supported rotation agree; +it cannot be classified by pathname or extension alone. `CustomVendorHook.log` +and `PolicyAgent.log.backup` remain unsupported. Current source candidates are +native concerns only: `%WINDIR%\\CCM\\Logs`, `%WINDIR%\\ccmsetup\\Logs`, and +explicitly configured roots. Pure intake must never reconstruct a path. + +Catalog matching is set-valued and single-capture. A physical candidate is +matched once by client role/provenance, exact declared basename, and supported +rotation to exactly one catalog entry. That entry supplies an already-sorted, +immutable set of logical group memberships. The collector creates one stable +physical artifact identity from the configured-root handle/path fingerprint, +basename, and rotation lineage, then the intake projection references that same +artifact from each membership; it does not copy or reclassify the file. +Consequently `LocationServices.log` is captured once through +`client-location-services-shared` and contributes to both `client-content` and +`client-location`. Catalog validation must reject a basename/role/rotation +tuple owned by more than one entry, rather than selecting the first matching +row. Input or catalog iteration order therefore cannot change classification. + +## Proposed manifest v1 adapter + +The fixture manifests are a reviewable proposed wire shape, not a claim that +the #318 reader accepts it. Before implementation, map each proposed field to +the published #318 field or remove it. Unknown future fields must be preserved +where #318 permits; unavailable detail must lower coverage/confidence rather +than being guessed. + +```json +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "LAB", + "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", + "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" + }, + "artifacts": [{ + "artifactId": "fixture-app-enforce-root-a-current", + "designOnlyCatalog": { + "entryId": "client-app-enforce", + "groupMemberships": ["client-app-enforce"] + }, + "role": "client", + "kind": "ccmLog", + "captureState": "captured", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "originalBasename": "AppEnforce.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/AppEnforce.log", + "pathFingerprint": "synthetic-root-a-app-enforce-current", + "rotation": {"kind": "current", "fragmentComplete": true}, + "sourceVersion": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T00:00:00Z", + "bytesCopied": 177, + "relativePath": "evidence/client-app-enforce/current/AppEnforce.log" + }] +} +``` + +`designOnlyCatalog.entryId` and `designOnlyCatalog.groupMemberships` are +fixture-design labels, not proposed final #318 field names. They make the +single-capture/multi-consumer invariant reviewable until #318 provides the +actual representation. `artifactId` denotes one physical candidate/fragment +record and must be unique within a bundle; it is not a logical group ID. +For captured and capped artifacts, `bytesCopied` is the exact copied-file byte +length, `encoding` is explicit, and `collectionLimit` distinguishes the +configured limit from whether it actually truncated the file. Expected +fixtures mirror those fields by physical artifact ID. Noncapture states carry +zero bytes and a null relative path without invented encoding/limit +provenance. + +An applied byte cap is inclusive and counts raw source bytes before decoding. +The captured payload is the exact source prefix through `byteLimit`, even when +the boundary splits a multibyte sequence, physical line, or logical CCM +record. The collector does not append a textual truncation marker, decode and +repair the prefix, or replace boundary bytes. It records +`bytesCopied == file size == byteLimit`, `fragmentComplete: false`, and an +exact digest suitable for fixture verification. Any decoding or parse failure +remains a coverage state and cannot turn error-looking retained text into a +terminal finding. + +Capture states in this proposed SCCM extension are `captured`, `absent`, +`accessDenied`, `capped`, `skipped`, `unsafePath`, `unsupported`, and +`legacyUnknownDetail`. They must be projected to the eventual #318 coverage +contract without changing generic `ArtifactStatus`. `Failed` from a legacy +generic manifest maps only to `legacyUnknownDetail`, never to `accessDenied`, +`capped`, `skipped`, or a parser failure. A legacy `collected` or `missing` +value may be mapped only when its provenance explicitly identifies a declared +client source; otherwise it remains incomplete/unclassified. + +The native adapter is deliberately small: `discover_client_sources` evaluates +allow-listed basenames under configured roots; `capture_client_bundle` applies +per-source file/byte caps and collision-safe destinations; and +`write_sccm_manifest_v1` serializes sorted extension records. It must +canonicalize a configured root, reject a symlink/reparse target outside that +root, use a testable access-status provider, and retain original paths only as +privacy-classified provenance. There is no Tauri command, UI, direct parser +filesystem access, globbing in the pure crate, or redefinition of CCM. + +The later native binding must use one opened source handle for the entire +capture transaction: stream the bounded retained bytes, compute +`declaredByteLength` and lowercase `contentSha256` over exactly those bytes, +and persist/hand off that same byte sequence before closing the handle. It must +not stat, hash, or reopen the path in separate authority steps, because a file +replacement between those operations would bind the manifest to bytes that +were never supplied. This pure-parser slice validates and consumes that +binding but does not implement or claim the Windows handle workflow. + +## Determinism, collision, and rotation rules + +- Sort manifest artifacts by catalog entry ID, normalized path fingerprint, + rotation rank, original basename, then physical artifact ID. Group memberships + are sorted independently. `expected.json` preserves the public group, + physical-artifact, unsupported-artifact, and coverage-gap order exactly; its + deduplicated fragment table and pending native provenance are sorted by + physical artifact ID. +- Use the declared `current, lo, numeric ascending, timestamp ascending` + capture order. Parsing may later use valid normalized timestamps for evidence + order; it may not infer a cross-artifact relationship from rotation order. +- Store each fragment under its catalog entry and physical identity. Same + basenames from distinct allowed roots receive distinct artifact IDs, + fingerprints, and relative paths; neither overwrites nor merges into the + other by basename. +- `.lo_`, `.N`, and a documented timestamp suffix are rotations only of an + explicit allowed basename. `.backup` and arbitrary suffixes are unsupported. +- Complete record timestamps must be valid and no later than `capturedUtc`. + Canonical evidence basenames retain replacement-extension `.lo_` and + numbered `.log.N` spellings. +- A rotation split at either logical-record boundary carries + `fragmentComplete: false`; it can provide raw-safe coverage but cannot create + a key, phase transition, or terminal finding by itself. +- An unknown source is retained as unsupported metadata, outside workflow + reducers. Reordering an input manifest must serialize to identical assessed + output after the public #318 normalizer exists. + +## Scenario matrix and expected test design + +| Scenario | Primary assertion | Conservative result | +| --- | --- | --- | +| `complete` | Every curated group is captured with an explicitly synthetic record. | Baseline intake coverage only; no workflow diagnosis. | +| `rotations` | Current, `.lo_`, and `.2` `AppEnforce` fragments group together in declared order. | Captured group; no inferred deployment state. | +| `collision` | Two current `AppEnforce.log` candidates from distinct roots retain unique IDs, fingerprints, and paths. | Both physical artifacts survive; basename does not overwrite or merge them. | +| `missing-root` | No configured client root was discovered. | Every curated source is absent coverage; never “client not installed.” | +| `access-denied` | `client-policy-agent` has denied access while all other represented sources remain distinct. | Policy readiness requests only `client-policy-agent`; no policy failure. | +| `capped` | `client-content` retains exactly 128 bytes of a marker-bearing, incomplete fragment containing error-looking text. | Deployment readiness is insufficient; the fragment cannot parse as a complete record or establish a terminal condition. | +| `skipped` (design-only) | An optional supplemental source is intentionally disabled. | Preserve an intentional skip, distinct from absence/failure. | +| `unsafe-path` (design-only) | A reparse/symlink escapes an allow-listed root. | Reject capture, record `unsafePath`, and request a safe configured root. | +| `legacy-mapping` (design-only) | Generic legacy `collected`/`missing` have explicit client provenance; `failed` has none. | Map only the first two; retain `legacyUnknownDetail` for failed. | + +The six committed fixture directories are intentionally the smallest corpus +for first intake tests. `skipped`, `unsafe-path`, and `legacy-mapping` remain +test-design cases until the additive native manifest and test-double boundary +exist; they must be added before #319 reaches its exit gate. Every production +behavior must begin with a focused red test. Required pure tests use typed, +unknown-field-denying expectations and exact normalized comparison across all +groups, fragments, physical artifacts, unsupported artifacts, and coverage +gaps. Mutation tests cover omissions, reordering, forged provenance, unknown +basenames, unsupported suffixes, source collisions, and every supported pure +coverage state. Native temp-directory tests cover caps, access-provider +results, escape rejection, and legacy mapping. Windows client collection +remains a separate acceptance gate. + +## Fixture privacy and sanitization + +- Fixtures use only `LAB-CLIENT-01`, the exact synthetic site code `LAB`, + RFC-style test UUIDs, and fake `APP-TEST-001`, `CONTENT-TEST-001`, and + `KB0000000` tokens. +- `SYNTHETIC://` paths are opaque fixture provenance, never real Windows paths. + No customer hostname, user, SID, tenant, certificate, token, serial, actual + deployment name, or customer log line is permitted. +- All timestamps, exact byte counts, encodings, collection limits, and record + text are fixed. Evidence is minimal, deterministic, and has no semantic + assertion beyond stated coverage. +- Each manifest has `proposalOnly: true` and `syntheticFixture: true`. The first + line of every evidence file contains the literal `SYNTHETIC FIXTURE` plus + scenario-specific coverage text: complete CCM fixtures place it inside the + first CCM record, the capped fixture retains it in an intentionally + incomplete 128-byte fragment, and non-CCM supplemental fixtures use it as + plain text. A production collector must not treat the marker as a real SCCM + file format. +- `expected.json` uses `contractState: pureIntakeImplementedNativePending`. + `pureAssessment` is the complete typed + executable oracle. Legacy fixtures intentionally omit the additive + length/digest authority and therefore remain assessment-only; + `nativeDesignPending` holds the proposed byte/limit facts until the native + adapter can populate the new binding from one source handle. + `downstreamDesignPending` labels request wording and prohibited claims that + are not intake output. Native manifest emission, discovery/capture, and + Windows acceptance remain design-only gates rather than delivered claims. + +## Remaining delivery blockers + +1. The additive SCCM native manifest reader/writer and bounded client + discovery/capture adapter are not implemented. The committed + `proposalOnly` manifest shape is test design, not a native wire acceptance + claim. +2. Pure coverage already keeps `captured`, `absent`, `accessDenied`, `capped`, + `skipped`, `unsupported`, and `parseFailed` distinct. Native `unsafePath` + and `legacyUnknownDetail` mapping still need their own additive manifest + and test-double contracts rather than being guessed into an existing state. +3. The public legacy generic-manifest adapter and tolerant unknown-field/enum + behavior remain unresolved; only provenance-backed `collected`/`missing` + may map forward. +4. A Windows SCCM development client and Windows CI are required to accept + actual canonicalization/reparse/ACL/rotation collection semantics. macOS + proves the pure projection, JSON, ordering, synthetic privacy, and future + native test doubles only. diff --git a/docs/sccm/preparation/issue-320-client-health-corpus.md b/docs/sccm/preparation/issue-320-client-health-corpus.md new file mode 100644 index 000000000..dcac52619 --- /dev/null +++ b/docs/sccm/preparation/issue-320-client-health-corpus.md @@ -0,0 +1,138 @@ +# Issue #320 client health corpus preparation + +## Purpose and dependency boundary + +This is a synthetic, preparation-only corpus for the client-only health +workflow in issue #320. It specifies evidence, expected conservative outcomes, +and future direct test assertions. It does not add a reducer, public Rust API, +Tauri surface, Cargo dependency, or compiled test while the reviewed #318 and +#319 interfaces are unavailable in this worktree. + +The intended workflow is strictly: + +```text +Setup -> Service -> Identity -> SiteAssignment -> ManagementPoint -> Transport +``` + +Each hop needs source-local, complete, profile-validated evidence. A captured +file, a hostname-shaped string, a timestamp coincidence, or an absent artifact +does not prove a hop. Findings remain client-side observations: this corpus +never asserts a site-server, management-point, DNS, proxy, or network root +cause. + +## Synthetic fixture wire shape + +Every scenario contains `manifest.json`, `expected.json`, and only the minimum +referenced `evidence/` files. The manifests deliberately retain the #319 +preparation shape and set both `proposalOnly` and `syntheticFixture` to true. +All evidence uses valid CCM logical-record syntax except the explicit malformed +or rotation-boundary cases. The `fragmentComplete` marker is a proposed #319 +capture detail: an incomplete fragment is coverage only and is never supplied +to a semantic reducer as a complete logical record. + +Expected records are future-test contracts, not current serialized API claims: + +- `contractState` remains `proposedPending318And319`. +- Every physical candidate has a globally unique `artifactId`; the logical + #319 design-only catalog identity is separately preserved as + `designOnlyCatalog.entryId`. +- `LocationServices.log` is captured once per scenario as + `client-location-services-shared`, with sorted `groupMemberships` of + `client-content` and `client-location`. Health consumes only the + `client-location` membership. +- Captured artifacts record exact `bytesCopied`, `encoding: "utf-8"`, and a + `collectionLimit`; non-captures use zero bytes and null capture-only fields. + Every usable record timestamp is at or before `capturedUtc`. +- `fixtureEvidence` uses the physical artifact ID plus an exact fixture-local + entry ID and physical line range. #318/#319 must define the public reader and + evidence-ID projection before these become compiled assertions. +- `nextArtifacts` is always the smallest logical client source that can answer + the unresolved hop. An absence or coverage gap creates only + `insufficientEvidence`, never a client failure. +- All arrays are pre-sorted by stable finding ID, artifact ID, then entry ID. + +## Scenario matrix + +| Scenario | Evidence focus | Required future assertion | +| --- | --- | --- | +| `success` | Complete keyed setup, service, identity, site, MP, and request/response sequence. | `lastSuccessfulPhase = transport`; no finding. | +| `setup-failure` | Profile-validated terminal setup record with no later matching recovery. | High `confirmedFailure` at `setup`; do not infer later hops. | +| `identity-failure` | Setup and service succeed, then identity registration has a terminal record. | High `confirmedFailure` at `identity`, never an MP failure. | +| `no-site-or-mp` | Setup/service/identity succeed; captured location source has no complete site/MP response. | Low `insufficientEvidence` at `siteAssignment`; request only `client-location`. | +| `transport-failure` | Same validated request ID and MP host connect a request to a terminal client transport error. | High client-side `confirmedFailure` at `transport`; never claim MP cause. | +| `contradictory` | Terminal setup error and later success have different validated bootstrap keys. | Low `symptom`; do not treat the later record as recovery. | +| `rotation-boundary` | A terminal-looking setup record is split across two incomplete rotations. | No phase advance or failure; low `insufficientEvidence` at `setup`. | +| `malformed` | Setup is valid but service source is an unclosed CCM record. | Low `symptom` at `service`; request only `client-evaluation`. | +| `incomplete` | Setup/service are valid; identity capture is access-denied and location is absent. | `lastSuccessfulPhase = service`; low `insufficientEvidence` at `identity`; request only `client-identity`. | + +## Future reducer and test specification + +Once #318/#319 publish reviewed contracts, add a dedicated +`sccm_client_health` test target that loads each manifest through the public +bundle reader and makes direct assertions (not permissive snapshots): + +1. Normalize complete CCM logical records source-locally and reject records + from `fragmentComplete: false` or malformed fragments before key extraction. +2. Classify only the #319 health memberships: `client-ccmsetup`, + `client-evaluation`, `client-identity`, and `client-location`. The shared + `client-location-services-shared` catalog entry is one physical capture, + not a second health-specific copy. +3. Apply only a reviewed ConfigMgr/artifact-family extraction profile. Unknown + versions or message patterns retain low-confidence safe evidence and cannot + establish a terminal state or validated correlation key. +4. Advance each phase only from positive evidence for that phase. Site and MP + need their own location evidence; a hostname in unrelated text is not an MP + success. +5. Permit a later recovery to supersede a terminal-looking record only when + the same validated key is present and the ordering is usable (valid resolved + UTC ordering, or a reviewed safe source-local order). The `contradictory` + case proves a different key cannot recover the earlier record; add a + focused mutation of that case with the same key and ordered success to prove + permitted recovery. +6. A transport failure needs a validated request/host context linking the + terminal response to the request. An unkeyed same-minute network error is a + low-confidence `symptom` only, as in `no-site-or-mp`. +7. Keep alternative evidence cited. Do not collapse it into a mutable global + client-health state, and sort output deterministically regardless of input + artifact order. + +Required direct assertions per `expected.json` are workflow name, last proven +phase, finding ID/class/phase/confidence, fixture evidence references, coverage +gap IDs, and ordered next logical artifacts. Tests must also assert every +finding summary/title is client-side and contains none of `server`, +`management point caused`, `DNS caused`, or equivalent causal language. + +## Exact unresolved #318/#319 mappings + +| Proposed corpus field or rule | Must be supplied/reviewed by | Status before implementation | +| --- | --- | --- | +| `manifest.json` reader, `sccmManifestVersion`, artifact grouping, and stable artifact ordering | #319 intake/bundle contract | Unresolved; #319 preparation is not a public reader. | +| `captureState`, `relativePath`, `pathFingerprint`, `fragmentComplete`, `unsafePath`, and legacy capture details | #319 manifest contract mapped to #318 coverage | Unresolved; current #318 coverage enum cannot by itself preserve all proposed distinctions. | +| Normalized complete logical records, source-local entry IDs, safe evidence redaction, and bundle evidence ordering | #318 evidence ingest/export contract | Unresolved. | +| `SccmPhase`, `SccmConfidence`, finding builder validation, evidence refs, artifact requests, and workflow analysis serialization | #318 shared finding/workflow contract | Unresolved. | +| Versioned health message profiles and validated client/site/MP/request/bootstrap key extraction | #318 key/profile contract plus #320 review | Unresolved; no regex or heuristic is frozen by this corpus. | +| Recognition of the four client health logical memberships, including the shared `client-location-services-shared` entry, and their coverage projection | #319 client catalog/intake contract | Unresolved. | + +The currently visible #318 work establishes coverage/rotation model beginnings +only; it does not authorize assumptions about the items above. Until all rows +are mapped, no production reducer or compiled test may deserialize these +proposed manifests against speculative APIs. + +## Privacy and replay rules + +- Every identifier is synthetic: `LAB-CLIENT-01`, exact three-character site + code `LAB`, RFC-style UUIDs, `.invalid` hosts, `BOOT-TEST-*`, and + `REQ-TEST-*` are fixture tokens only. +- `SYNTHETIC://` is opaque provenance. No real endpoint path, user, SID, + certificate, token, tenant, serial, deployment, or customer log content is + permitted. +- The synthetic marker is embedded in the first semantic CCM record, never a + marker-only line. A closing rotation fragment intentionally has no standalone + marker or invented semantic record. +- Fixed timestamps and byte counts are intentional. A future reader must not + add dynamic IDs, current time, temporary paths, or external error-database + wording to expected output. +- Replay JSON validation, referenced-file validation, artifact ordering, + privacy-marker validation, and `git diff --check` are valid now. Native + Windows collection, ACL, reparse, and rotation acceptance remain separate + #319/Windows gates. diff --git a/docs/sccm/preparation/issue-321-client-policy-corpus.md b/docs/sccm/preparation/issue-321-client-policy-corpus.md new file mode 100644 index 000000000..484a6f046 --- /dev/null +++ b/docs/sccm/preparation/issue-321-client-policy-corpus.md @@ -0,0 +1,211 @@ +# Issue #321 client policy corpus preparation + +## Purpose and dependency boundary + +This document and its synthetic fixtures prepare Task 5 of the SCCM Client +intake/core plan. They define behavior-first policy workflow cases without +implementing a reducer, parser interface, or production schema. Every fixture +uses `contractState: proposedPending318`; field names under this preparation +contract are review labels until #318 publishes the shared artifact, evidence, +key, phase, finding, coverage, and request types. + +#321 also depends on #319 for the final physical artifact/manifest contract. +The fixtures therefore follow #319's reviewed design shape now: a physical +`artifactId` is distinct from `designOnlyCatalog.entryId`; group memberships +are sorted; capture provenance is exact; and a physical file is referenced +rather than copied once per logical consumer. No production code, native +collection behavior, or speculative #318 interface is part of this slice. + +The policy reducer must remain independently callable. It consumes policy +artifacts and their normalized evidence directly. It never consumes the output +of the health, deployment, update, or future correlation reducer. A bounded +request for `client-location` in the request-auth scenario is a coverage +dependency only, not health-reducer input and never evidence of an MP cause. + +## Policy state contract + +```text +Request -> Download -> Persist -> Schedule -> Evaluate -> Report +``` + +- `Request` is an evidenced client policy request/authentication outcome. +- `Download` is an evidenced policy transfer outcome for the same exact key. +- `Persist` is an evidenced client-side policy persistence outcome. +- `Schedule` is an evidenced scheduler disposition. `Deferred` is a first + class state, not a failure or an evaluation result. +- `Evaluate` is an evidenced policy evaluation outcome. +- `Report` is an evidenced state/report outcome. + +The last successful phase is the latest phase supported by coherent evidence, +not the phase before the newest line by filename or ingestion order. Absence +cannot prove success or failure. + +## Source-family and physical identity design + +| Catalog entry | Physical basenames used here | Policy responsibility | +| --- | --- | --- | +| `client-policy-agent` | `PolicyAgent.log`, `Scheduler.log`, supported `PolicyAgent.lo_` rollover | Request, Download, Persist, Schedule | +| `client-policy-state` | `CIAgent.log`, `StateMessage.log` | Evaluate, Report | +| `client-location` | absent `ClientLocation.log` only in request-auth coverage | Bounded missing client-side context; no policy phase and no MP conclusion | + +Each artifact retains a globally unique synthetic physical ID, one catalog +entry, one sorted membership, a synthetic path handle, a distinct path +fingerprint, and exact basename/rotation metadata. Captured artifacts also +retain one relative evidence path. `captured` and `capped` artifacts declare +`encoding: utf-8`, an explicit +`collectionLimit`, and a `bytesCopied` value equal to the physical file. +Noncapture artifacts use zero bytes and a null relative path without invented +encoding or limit provenance. + +Complete evidence files are forced through the existing CCM grammar. The +literal `SYNTHETIC FIXTURE` appears inside the first semantic CCM record and is +never a marker-only line. A split rotation sets `fragmentComplete: false`; no +individual fragment can yield a complete record, key, phase, or terminal +finding. A syntactically complete record may still carry an invalid offset. +Valid offsets normalize to UTC and must be no later than the artifact's +`capturedUtc`; the original display and offset remain cited. Invalid or unknown +offsets remain raw, non-comparable ordering evidence with no normalized UTC +instant and cannot raise confidence. + +## Version-profiled key contract + +The selected preparation profile is +`policy-client-5.00.test-v1`, scoped only to the synthetic version prefix +`5.00.TEST.` and the declared policy source families. This is not a claim about +an observed production ConfigMgr version. + +A keyed transaction requires normalized `assignmentId` and `policyId` UUIDs +extracted as `exact` under that profile. When a complete profile-recognized +Request record directly supplies them, its counterpart-ready fact also carries +an exact `requestId`, correlation-safe client handle, three-character +`siteCode`, selected/observed management-point host handle, selection kind, and +the Request evidence reference. These optional fields remain absent when the +source cannot prove them. Filename, bundle capture host, component, message +proximity, and timestamp alone never create or fill a transaction key. An +unvalidated version, malformed key, or rotation-split key remains a +source-local observation with: + +- no transaction key; +- `keyConfidence: none`; +- `confidence: low`; +- `confidenceCeiling: low`; and +- `correlationEligible: false`. + +Such evidence cannot be attached later by time or by some other reducer. + +## Reducer and false-causality rules + +1. Reduce one exact assignment/policy key at a time and stable-sort the final + transactions, findings, observations, and evidence references. +2. Preserve repeated observations. An ordered, explicit terminal success may + prove recovery from an earlier terminal-looking result only with the same + exact key and coherent timestamp/source ordering. +3. Same-key success/failure facts at the same resolved instant across + independent physical sources remain contradictory when no trusted ordering + resolves them. The confidence ceiling is low. +4. Normalize valid offsets before ordering. An invalid/unknown offset cannot + order evidence across artifacts, and matching display time alone cannot + create causality or raise confidence. +5. Same-minute facts with different exact keys remain separate transactions. + They never qualify or overwrite one another. +6. `Deferred` maps to `blockedOrDeferred`, never `confirmedFailure`. +7. A terminal failure names only the client phase evidenced. It does not infer + management-point authentication, availability, or server root cause. +8. An incomplete path requests the smallest catalog group: + `client-policy-agent` for Request through Schedule and + `client-policy-state` for Evaluate/Report. +9. Reordering manifest artifacts or evidence inputs must produce byte-equal + normalized analysis after #318 supplies the normalizer. + +## Scenario matrix + +| Scenario | Expected result | Last successful phase | Bounded next artifact | +| --- | --- | --- | --- | +| `complete` | Clean success through Report with no failure or recovery branch. | Report | None | +| `recovery` | Success through Report after an ordered same-exact-key Download failure then explicit later Download success. | Report | None | +| `request-auth-failure` | Client Request failure with missing location coverage; no MP cause. | None | `client-location` | +| `download-failure` | Confirmed client Download failure. | Request | None | +| `persist-failure` | Confirmed client Persist failure. | Download | None | +| `scheduler-deferred` | Blocked/deferred scheduler disposition, not failure. | Persist | `client-policy-agent` | +| `evaluation-failure` | Confirmed client Evaluate failure. | Schedule | None | +| `reporting-failure` | Confirmed client Report failure. | Evaluate | None | +| `rotation-split` | Keyless low-confidence insufficient evidence. | None | `client-policy-agent` | +| `malformed` | Keyless low-confidence symptom under an unvalidated version. | None | `client-policy-agent` | +| `incomplete` | Exact transaction stops at Schedule because policy-state coverage is absent. | Schedule | `client-policy-state` | +| `multiline` | Clean success with Request framed as one complete logical CCM record across two physical lines. | Report | None | +| `contradictory-offset` | Assignment A orders valid offsets by normalized UTC despite reversed display order; assignment B stays low/contradictory because one offset is invalid and non-comparable. Same-display-time different keys remain isolated. | A: Report; B: Schedule | A: none; B: `client-policy-state` | +| `gate-c-contradictory` | Assignment A retains an unresolved same-normalized-instant Evaluate contradiction across independent physical artifacts with no source-local or lineage order; unrelated assignment B remains a separate Report failure. | A: Schedule; B: Evaluate | A: `client-policy-state`; B: none | + +The repaired corpus contains 14 scenarios, 41 artifacts (39 captured and 2 +noncapture), 39 evidence files, 69 complete CCM records, 2 deliberately +incomplete rotation fragments, 14 exact transactions, 12 nonsuccess findings, +and 2 keyless source-local observations. The 39 evidence files total exactly +21,396 bytes. The focused byte coordinator's path-and-artifact-qualified +aggregate SHA-256 is +`15acfe9cf467b64a2ebcb0896a6e8e6cb12400e37eb448ec8de6200938e0d387`. + +## Expected-output preparation labels + +Each `expected.json` includes: + +- the full state chain and independent-reducer contract; +- extraction-profile selection or explicit unvalidated-version state; +- stable transactions and/or keyless source-local observations; +- exact phase, state, last successful phase, classification, confidence, and + confidence ceiling; +- exact physical artifact/line-range evidence references; +- a bounded `nextArtifact` object or explicit `null`; +- one finding per nonsuccess subject; +- capture-provenance assertions by physical artifact ID; +- full physical-line spans for complete multiline logical records; +- preserved display/offset plus normalized or explicitly non-comparable + ordering provenance; +- deterministic reordered-input expectation; and +- prohibited management-point/server, #333, and device-wide merge claims. + +These labels describe the behavior that future #318-backed tests must assert; +they are not proposed final public field names. + +## Privacy and evidence limits + +All host/site/path/version/key values are deterministic synthetic labels. +Declared ConfigMgr site codes are exactly three alphanumeric characters. +Allowed identities are `LAB-CLIENT-01`, the synthetic site code `LAB`, +correlation-safe handles such as `safe:client:policy-11` and +`safe:mp:lab-mp-01`, synthetic UUIDs, and `SYNTHETIC://` opaque path handles. +Fixtures contain no customer path, hostname, user, SID, tenant, token, +certificate, serial, deployment name, or copied production log text. +Error-looking codes are synthetic workflow facts, not external error-database +conclusions. + +## #333 handoff + +#321 exposes exact, profile-qualified assignment/policy keys and, only when a +recognized Request record directly proves them, request ID, correlation-safe +client handle, three-character site code, selected/observed MP host handle, +client-side phase, ordering provenance, and evidence references. The declared +counterpart-ready key kinds are `requestId`, `policyId`, `clientSafeHandle`, +`siteCode`, and `managementPointHostHandle`. Missing or unvalidated Request +evidence emits no counterpart-ready fact; neither `LAB-CLIENT-01` nor capture +time may be repurposed as MP-selection evidence. + +#333 owns topology compatibility and the adversarial exact-key/different-site +or different-MP classification. It must independently require compatible +server evidence, topology, ordering, and coverage before correlating +policy-to-MP behavior. This corpus performs no topology match, cross-side +matching, or server claim. + +## Replay and acceptance gates + +Before implementation, map these design labels to the published #318/#319 +contracts. Then load each fixture through the public reader, run only the +independent policy reducer, repeat with reversed/shuffled input, and compare +normalized output. Validate JSON, exact bytes, paths/references/no orphans, +privacy, forced CCM grammar, multiline framing, partial boundaries, valid +offset normalization, invalid-offset non-comparability, chronology, +key/profile ceilings, parser regression tests, strict Clippy, wasm32, and +TypeScript. + +#318 and #319 remain explicit blockers for compiled policy tests. #333 is a +later correlation handoff, not a blocker that authorizes cross-side behavior +inside #321. diff --git a/docs/sccm/preparation/issue-322-client-deployment-corpus.md b/docs/sccm/preparation/issue-322-client-deployment-corpus.md new file mode 100644 index 000000000..11dfbb115 --- /dev/null +++ b/docs/sccm/preparation/issue-322-client-deployment-corpus.md @@ -0,0 +1,184 @@ +# Issue #322 client deployment/content corpus preparation + +## Purpose and dependency boundary + +This slice prepares the application, package, and content behavior contract +from Task 6 of the SCCM Client intake/core plan. It contributes a direct fixture +contract plus a fully synthetic corpus; it does **not** add a production +reducer, native collector, or speculative shared model. Every expected output +is marked `proposedPending318And319` until #318 publishes the shared diagnostic +types and #319 freezes the client physical-artifact and manifest interfaces. + +The future deployment reducer must be independently callable. It consumes +normalized deployment evidence, not the output of the policy reducer. The +`success` scenario deliberately records `client-policy-agent` as `absent` and +still reaches Report from its own cited evidence. Conversely, no scenario uses +missing policy coverage to manufacture a deployment conclusion. + +## Deployment state and evidence contract + +```text +Intent -> Requirements -> LocateContent -> Transfer -> Cache -> Enforce -> Detect -> Report +``` + +Each phase advances only on a complete, profile-recognized record for the same +validated transaction key. A terminal requirement or dependency record stops +before LocateContent. Transfer, cache, enforcement, detection, and reporting +remain distinct outcomes. In particular: + +- a content request without a complete response is a client LocateContent gap, + not evidence that a distribution point lacks content; +- a terminal BITS record is a client Transfer failure and retains its job key; +- a terminal cache record is a Cache failure, not a rewritten Transfer result; +- a nonzero exit is high-confidence only here because the same exact + AppEnforce record is explicitly terminal; +- a false post-enforcement detection record is a detection symptom, not proof + that installation or content delivery caused it; and +- an explicit not-applicable intent is `notTargeted`, not failure. + +Every nonterminal gap names the smallest bounded client source family to +collect next. Missing, access-denied, capped, and partial sources remain +coverage states. + +## Sources and physical identity + +| Design-only catalog entry | Synthetic basenames | Responsibility | +| --- | --- | --- | +| `client-app-intent` | `AppIntentEval.log`, `AppDiscovery.log` | Intent, requirements, dependencies, detection | +| `client-content` | `CAS.log`, `CAS.lo_`, `DataTransferService.log` | Content request/topology, transfer, cache | +| `client-app-enforce` | `AppEnforce.log` | Terminal enforcement result | +| `client-policy-state` | `StateMessage.log` | Final deployment report only | +| `client-installer-supplemental` | `InstallerSupplemental.log` | Low-confidence source-local context only | +| `client-policy-agent` | absent `PolicyAgent.log` in `success` | Explicit proof that deployment output does not depend on policy-reducer output | + +Physical artifacts retain distinct artifact IDs, sanitized `SYNTHETIC://` +source paths, safe relative evidence paths, exact byte counts, encoding, +collection-limit provenance, rotation kind, and source version. Noncapture +artifacts have no invented path, encoding, or collection-limit provenance. +The canonical archived suffix is `.lo_`; `.log.lo_` is forbidden. + +Complete CCM evidence is passed through the existing raw CCM grammar and +contains a `SYNTHETIC FIXTURE` marker inside a semantic record. The rotation +case intentionally splits one would-be record across `CAS.lo_` and `CAS.log`. +Both physical artifacts are marked incomplete and must remain separate. The +capped AppEnforce prefix is also incomplete and source-local. + +## Versioned keys and deterministic grouping + +The proposed synthetic extraction profile is +`deployment-client-5.00.test-v1`, restricted to source version prefix +`5.00.TEST.` and the declared source families. It is not a claim about a live +ConfigMgr build. + +Transaction priority is: + +1. exact assignment ID plus CI ID; +2. exact package/content/version only when corroborated by the assignment/CI; +3. otherwise a source-local candidate capped at low confidence. + +Exact content handoff facts additionally retain package ID, content ID, +content version, correlation-safe distribution-point host handle, request ID, +explicit-offset timestamp provenance, and the exact client evidence reference. +BITS job, product code, and exit code stay transaction-local where observed. +Filename, component, display name, ingestion order, and time never create or +merge a key. + +Transactions, observations, findings, provenance, and evidence references have +stable IDs/order. Reordering inputs is required to produce the same normalized +future output. The `incomplete` scenario contains two assignment/CI pairs at +the same timestamp and proves they stay separate. + +## Scenario matrix + +| Scenario | Expected disposition | Last successful phase | Bounded next source | +| --- | --- | --- | --- | +| `success` | Success through Report with cited detection/report evidence | Report | None | +| `not-targeted` | Explicit not-applicable classification; not a failure | None | None | +| `requirements-failure` | Confirmed requirement failure | Intent | None | +| `dependency-failure` | Confirmed dependency failure | Intent | None | +| `location-missing` | Missing content coverage; insufficient evidence | Requirements | `client-content` | +| `dp-content-missing` | Exact client request without a terminal response; no DP diagnosis | Requirements | `client-content` | +| `bits-transfer-failure` | Confirmed client Transfer failure with BITS key | LocateContent | None | +| `cache-failure` | Confirmed client Cache failure after successful Transfer | Transfer | None | +| `enforcement-exit` | Confirmed terminal AppEnforce failure; unkeyed installer text remains local | Cache | None | +| `detection-false-negative` | Detection mismatch after successful enforcement | Enforce | None | +| `rotation-boundary` | Incomplete physical fragments; low-confidence gap | Requirements | `client-content` | +| `incomplete` | Two same-time exact transactions with access-denied content and capped unkeyed enforcement | Requirements | `client-content` | + +The corpus has 12 scenarios, 36 artifacts, and 33 evidence files totaling +exactly 16,840 bytes. Capture states are 32 captured, one capped, one +access-denied, and two absent. The path-and-artifact-qualified evidence content +digest is SHA-256 +`27e0f8b6fab7bc584902718229824a45bdab9d1c9c78601e04f9d571e34c5c53`. +The checked-in Rust contract hashes each physical file, builds sorted rows as +`scenario NUL artifactId NUL relativePath NUL fileSha256 LF`, then hashes the +concatenated rows. This binds both identity and bytes rather than relying on +file length. + +## Supplemental and unknown evidence + +Supplemental MSI, PSADT, or Burn output may later enrich a transaction only +when its provenance and stable key satisfy the reviewed #318/#319 contract. +The unkeyed installer line in `enforcement-exit` is deliberately simultaneous +with the exact AppEnforce result, yet remains `keyConfidence: none`, +`confidenceCeiling: low`, and `correlationEligible: false`. It cannot override +the SCCM phase. + +Likewise, an unknown extraction profile, malformed code, incomplete logical +record, or code with no reviewed semantic mapping may be retained as cited raw +source-local evidence. It cannot be promoted into a known-code diagnosis or +an exact transaction merely because it resembles a familiar code. + +## #333 content-to-DP handoff + +Only six scenarios emit a proposed `clientContentRequest` fact: +`success`, `dp-content-missing`, `bits-transfer-failure`, `cache-failure`, +`enforcement-exit`, and `detection-false-negative`. Each fact carries the exact +profile-qualified package/content/version/DP-handle/request key, client +LocateContent phase, usable explicit-offset provenance, and evidence. + +#333 must independently require a compatible #329 server fact, compatible +topology, usable ordering, complete coverage, and corroborating or terminal +evidence. This corpus performs no topology evaluation or cross-side +correlation. Same time, a matching display label, or a client request alone +cannot establish a distribution-point or server cause. + +## Privacy and acceptance limits + +All identities, keys, paths, messages, codes, and versions are deterministic +synthetic values. The site code is the three-character value `LAB`; hostnames +and topology use correlation-safe synthetic handles. The corpus contains no +customer name, user profile, SID, email, token, certificate, tenant, device +serial, deployment display name, or copied production log text. + +This preparation is parser-only. It does not claim native Windows collection, +live ConfigMgr compatibility, or SCCM Server lab acceptance. + +## Replay gates + +Run the checked-in preparation contract: + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_deployment_fixture_contract +``` + +That Rust target contains the runnable exact-byte/digest, inventory, +manifest-to-coverage, no-orphan/no-alias, CCM logical-record, physical +rotation-boundary, citation/key/timestamp, confidence-ceiling, safe-path, and +privacy validation. It has no external script or machine-specific path +dependency. + +Before merging implementation against published interfaces, also run: + +```bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +npx tsc --noEmit +git diff --check +``` + +The future implementation must first map these preparation labels to the +reviewed #318/#319 contracts and request a false-causality review. Passing this +corpus alone is not an issue-closure condition. diff --git a/docs/sccm/preparation/issue-323-client-updates-corpus.md b/docs/sccm/preparation/issue-323-client-updates-corpus.md new file mode 100644 index 000000000..0fce31344 --- /dev/null +++ b/docs/sccm/preparation/issue-323-client-updates-corpus.md @@ -0,0 +1,244 @@ +# Issue #323 client software-update corpus preparation + +## Purpose and dependency boundary + +This document and the synthetic fixture corpus prepare Task 7 of the SCCM +Client intake/core plan. The slice defines evidence-first client +software-update behavior without implementing `analyze_client_updates`, a +production reducer, native collection, or a private replacement for #318's +shared contracts. + +Production implementation remains dependent on the reviewed #318 artifact, +logical-record, evidence, timestamp, signal, key, redaction, and finding +contracts plus #319's final client manifest/intake surface. Every expected file +therefore declares `contractState: proposedPending318`: the labels are the +behavior future code must preserve, not proposed final public field names. + +The updates reducer must remain independently callable. It consumes normalized +update evidence directly and declares its own gaps. It never consumes policy, +deployment, health, server, or correlation reducer output as a shortcut. A +missing policy artifact may be a coverage fact when update reporting requires +`StateMessage.log`; it is not permission to call the policy reducer or inherit +its result. + +## Client update state contract + +```text +Scan -> Evaluate -> LocateSup -> Download -> MaintenanceWindow + -> Install -> Reboot -> Report +``` + +- `Scan` requires a profile-recognized client scan outcome. +- `Evaluate` requires metadata/compliance applicability evidence for the same + exact update key. +- `LocateSup` is a client observation that a specific safe SUP/location handle + was selected/used. It does not prove the SUP server is healthy. +- `Download` is client content-transfer evidence with a validated + update/content/job key. It does not prove a DP or SUP root cause. +- `MaintenanceWindow` preserves an explicit wait/defer state separately from + failure. +- `Install` requires source-specific install disposition; a generic error code + alone is only a signal. +- `Reboot` preserves pending/deferred separately from install failure. +- `Report` requires exact client report/state evidence. + +The last successful phase is the latest coherently evidenced phase for one +exact key. Existence of a file, filename order, bundle artifact order, display +time, or an error-looking token cannot advance/fail the state machine. + +## Design-only source groups + +| Preparation group | Basenames exercised | Responsibility | +| --- | --- | --- | +| `client-updates` | `ScanAgent.log`, `WUAHandler.log`, `UpdatesDeployment.log`, `UpdatesHandler.log`, `UpdatesStore.log` | scan, evaluate, update disposition, install | +| `client-location-services-shared` | `LocationServices.log` | client-observed SUP/location selection | +| `client-content` | `DataTransferService.log`, `ContentTransferManager.log` | download/content state | +| `client-maintenance-window` | `ServiceWindowManager.log` | maintenance-window disposition/context | +| `client-reboot` | `RebootCoordinator.log` | reboot pending/completion | +| `client-policy-state` | `StateMessage.log` | update report/state output only; no policy-reducer dependency | +| `client-windows-update-supplemental` | separately typed `CBS.log` plus skipped/unsupported `ReportingEvents.log`/CBS candidates | optional corroboration/capability only | + +These are preparation labels. Shared catalog admission belongs to #319/#318 API +review. No entry in this corpus adds a raw parser or broad unsupported source +family. + +## Version-profiled key and evidence contract + +The only selected preparation profile is +`updates-client-5.00.test-v1`, scoped to synthetic source versions beginning +`5.00.TEST.` and the declared update artifacts. It makes no claim about a +production ConfigMgr build. + +The shared `sccm-keys-5.00.9128-experimental-v1` profile remains Low confidence. +This corpus cannot promote its keys to an exact/terminal transaction or emit a +correlation-eligible counterpart fact from them. + +A transaction or counterpart-ready fact requires profile-validated exact +values: + +- `updateId`; +- `ciId`; +- `contentId`; +- `updateJobId`; +- `clientHandle`; +- three-character `siteCode`; and +- `supHostHandle` when a complete client `LocateSup`/equivalent record directly + supplies it. + +Keys are not filled from `LAB-CLIENT-01`, filenames, component names, display +names, time proximity, bundle capture host, or another reducer. Malformed, +unknown-version, rotation-split, capped, or invalid-offset evidence retains a +source-local/limited observation with a low confidence ceiling where +appropriate. It cannot later become exact through proximity. + +All required transaction fields must co-occur in one cited complete CCM record; +fields from adjacent/same-minute records cannot form a key. Every declared +success, confirmed failure, or blocked/deferred current phase requires a +compatible cited source record containing the exact key plus the claimed phase +disposition. Every non-null `lastSuccessfulPhase` independently requires a +compatible cited complete record containing the exact key and that phase's +successful disposition. An unproven prior phase is `null`, not inferred from +phase order. + +Every evidence reference names a physical artifact and inclusive physical line +range. Complete logical CCM records are one or more physical lines only when +the manifest proves a complete fragment; the partial rotation/capped inputs +cannot yield an entry/key/terminal fact. + +Correlation-ready facts bind their normalized UTC instant, numeric offset, and +ordering state to the cited complete CCM record. An unavailable SUP handle is +represented as `null`; it is never inferred from the capture host, another +transaction, or a timestamp. These remain counterpart-ready client facts, but +`correlationEligible` stays false while +`topologyCompatibilityEvaluated` is false. A fact may not self-attest +`topologyCompatible`, including an incompatible, null, or malformed value; +issue #333 must evaluate topology before correlation can become eligible. + +## Supplemental servicing boundary + +CBS, DISM, Windows Update, and ReportingEvents evidence remains separately +typed with explicit provenance. `CBS.log` uses `cbsLog`, while +`ReportingEvents.log` uses `supplementalLog`; neither carries a ConfigMgr +`sourceVersion`. The `supplemental-conflict` case proves that an +unkeyed CBS error at the same instant as exact client install success remains a +low-confidence supplemental symptom. It cannot override the client phase, +merge by time, or create an SCCM/SUP server cause. + +A future reducer may attach supplemental evidence only after compatible +source/profile provenance and an exact update/KB/CI match. Missing optional +supplemental evidence does not prevent a complete client result when the +client sources themselves prove it. + +## Scenario matrix + +| Scenario | Required outcome | Last successful phase | Coverage/request boundary | +| --- | --- | --- | --- | +| `success` | Succeeds through Report; optional ReportingEvents is skipped without degrading the client result. | Report | No request | +| `no-sup` | Insufficient client location/SUP evidence; no server health claim. | Evaluate | `client-location-services-shared` | +| `scan-failure` | Profile-recognized terminal client Scan failure. | None | No inferred downstream cause | +| `evaluation-failure` | Terminal client Evaluate failure after Scan success. | Scan | No SUP/content claim | +| `content-failure` | Terminal client Download failure with exact content/job evidence. | LocateSup | No DP/SUP cause | +| `maintenance-window` | Blocked/deferred because next-window context is unavailable. | Download | `client-maintenance-window` | +| `reboot-pending` | Blocked/deferred, explicitly not install failure. | Install | `client-reboot` continuation | +| `install-failure` | Terminal Install failure under the exact update key. | MaintenanceWindow | No server claim | +| `reporting-failure` | Terminal Report failure after evidenced Reboot completion. | Reboot | No policy-reducer dependency | +| `supplemental-conflict` | Client install success plus unkeyed conflicting CBS symptom; no override/merge. | Install | Keyed supplemental evidence only | +| `incomplete` | Stops after Download because MW/reboot/report artifacts are absent coverage. | Download | `client-maintenance-window` | +| `rotation-boundary` | Two partial `ScanAgent` fragments produce no key/transaction/cause. | None | Bounded complete `client-updates` recapture | +| `capped` | Exact 128-byte incomplete content prefix cannot establish Download failure. | LocateSup | `client-content` | +| `access-denied` | Scan evidence plus inaccessible update-handler source remains insufficient. | Scan | `client-updates` | +| `malformed` | Unknown-version malformed key plus parse-failed/unsupported coverage stays keyless/low. | None | `client-updates` | +| `invalid-offset` | Same-key cross-artifact ordering is non-comparable and capped low. | Scan | Comparable `client-updates` evidence | +| `same-minute-separate` | Two exact update keys at the same instant remain two transactions. | Report / None | Never time-merge | + +`BlockedOrDeferred`, `InsufficientEvidence`, and low-confidence symptoms are +not terminal failures. `Absent`, `AccessDenied`, `Capped`, `Skipped`, +`Unsupported`, `ParseFailed`, malformed, and partial sources are coverage or +capability states. + +## Future #330/#333 handoff + +The corpus exposes only exact, profile-qualified client facts. For a proven +client SUP interaction, a counterpart-ready fact may retain update/CI/content +and job IDs plus safe client/site/SUP handles, client phase, ordering +provenance, and exact evidence reference. + +The handoff explicitly records: + +- #330 is the server SUP prerequisite; +- #333 owns any future pairwise correlation; +- time alone is never eligible; +- topology compatibility is not evaluated here; +- no topology compatibility value or correlation eligibility is claimed before + #333 evaluates topology; +- bundle capture host is not SUP evidence; +- no server cause is claimed; and +- missing/unvalidated client source evidence emits no counterpart-ready fact. + +Software-update/SUP correlation must not begin until #323 and #330 each publish +stable, reviewed source facts and #333 defines the pairwise contract. + +## Determinism, privacy, and corpus identity + +All 17 scenarios use role `client`, capture host `LAB-CLIENT-01`, exact site +code `LAB`, `SYNTHETIC://` provenance, deterministic artifact IDs, sorted +artifact/coverage/transaction arrays, and stable synthetic keys/handles. +Expected coverage and artifact provenance are exact, one-to-one projections of +the manifest. Absent/skipped sources omit physical-fragment completeness, and +validated profile families are derived only from compatible captured evidence. +Client role, catalog entry, logical group, basename, rotation, and evidence path +must remain coherent. Relative paths and path fingerprints cannot alias another +artifact. A public fingerprint is bounded to `synthetic:` with one +lowercase alphanumeric/hyphen opaque segment. A public sanitized source path is +bounded to the committed `SYNTHETIC://root-a/` CCM, CBS, or Windows Update +namespace and must end in the exact declared basename; identity, domain, +control-character, drive, user-profile, and basename-substitution paths fail +closed. Every captured or capped physical artifact must also carry a non-empty +path fingerprint; missing, null, empty, and whitespace-only values are invalid +provenance rather than collision-safe identity. + +The corpus contains: + +- 51 manifest artifacts; +- 42 captured, 1 capped, 4 absent, 1 access-denied, 1 parse-failed, + 1 unsupported, and 1 skipped state; +- 43 physical evidence files totaling 23,142 bytes; +- 61 physical evidence lines; +- 57 complete CCM logical records; +- 2 deliberately partial rotation files and 1 deliberately capped physical + prefix; and +- no orphan evidence files. + +The exact capped 128-byte prefix has SHA-256 +`a0afd1fa4e1204c6d085886ed62b07f6b1d4af119f747c181f6e206194db9f7f`. +The path-qualified corpus SHA-256 is +`b7670821f385f90eb0178528480307f617c508c28abacf21e927d30ed3bdffef`, +computed by sorting evidence paths relative to the updates root and hashing +each UTF-8 path, one NUL byte, then its committed bytes in sequence. The +focused Rust contract also pins a path-qualified FNV-1a value +`0x1ff672e51adbeb52`. + +No evidence contains customer paths, hostnames, users, SIDs, tenants, tokens, +certificates, serials, production deployment names, or copied live log text. + +## Replay and acceptance gates + +Before implementation, map these preparation labels to the final #318/#319 +types. Then load through the public reader and run the independent update +reducer with original/reversed/shuffled input. Compare normalized serialized +output and validate key/profile confidence, line ranges, redaction, coverage, +offset comparability, stable ordering, and the no-server-cause boundary. + +Current preparation replay: + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_updates_fixture_contract +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +npx tsc --noEmit +``` + +Native Windows source discovery/capture is not exercised by this slice. Issue +`#323` must remain open for production implementation, shared-interface review, +and eventual authorized development-client validation. diff --git a/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md b/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md new file mode 100644 index 000000000..fa46cc606 --- /dev/null +++ b/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md @@ -0,0 +1,299 @@ +# Issue #324 client Task Sequence corpus preparation + +## Purpose and dependency boundary + +This slice prepares the client Task Sequence source-path, execution-key, and +phase contract from Task 8 of the SCCM Client intake/core plan. It contributes +a runnable fixture contract and a fully synthetic corpus. It does **not** add a +production Task Sequence reducer, a catalog entry, native collection, or a +speculative shared model. + +Every expected output is marked `proposedPending318And319`. Production work +must wait until #318 publishes the shared diagnostic types and #319 freezes the +client artifact/manifest interfaces. The future Task Sequence reducer must be +independently callable and consume normalized Task Sequence evidence directly. +It must not consume application- or policy-reducer output. + +## Source paths and relocation + +Microsoft documents that `smsts.log` moves as Task Sequence execution advances: + +| Path class | Documented stage represented by the synthetic fixture | +| --- | --- | +| `winpe` | WinPE before the disk is formatted | +| `setup` | WinPE after format | +| `fullOs` | New operating system before the Configuration Manager client is installed | +| `client` | Client-installed path, including the final relocated `smsts.log` | +| `unknown` | Observed path that no reviewed profile recognizes | + +The checked-in values are sanitized `SYNTHETIC://` handles, not copied Windows +paths. Each captured artifact pins: + +- a physical artifact ID and safe repository-relative path; +- the original basename and rotation kind; +- the sanitized capture source path; +- either the `_SMSTSLogPath` value observed in that physical artifact or an + explicit null whenever that artifact contains no such token, including a + complete WinPE record collected before a stable hard-drive path exists; +- a path class and relocation ordinal; +- the source version, capture timestamp, encoding, and exact byte count; and +- independently, whether that physical fragment is a complete logical CCM + record. + +An observed `_SMSTSLogPath` is the authoritative in-record path observation. +A sanitized capture source path remains capture provenance; it cannot +fabricate an in-record observation. A filename, display name, timestamp, +directory name, assumed operating-system stage, or shared fingerprint cannot +invent relocation, supply another physical artifact's path evidence, or merge +two artifacts. + +The `relocated-fragments` scenario pins the order: + +```text +winpe -> setup -> fullOs -> client +``` + +All four fragments carry the same exact execution key. The order is explicit +in `relocationOrdinal`; ingestion order is irrelevant. + +## Unsupported boot and recovery variants + +This corpus validates only the five declared path classes and the sanitized +pre-format, post-format, pre-client, client-installed, and completed examples +in the scenario matrix. It does not validate PXE versus boot-media behavior, +standalone or prestaged media, Windows recovery/rollback environments, +alternate system-drive layouts, resumed setup paths not represented here, or +any vendor-specific recovery environment. + +An unobserved boot or recovery variant is an explicit coverage/profile gap. +The `unknown` path class preserves such provenance without asserting support. +It must not be reclassified from a familiar filename, and it must not trigger +an unbounded disk search. Native Windows validation must record the ConfigMgr +and OS deployment profile, boot context, observed path class, and variants +that were not observed before support is expanded. + +## Execution identity + +The proposed synthetic extraction profile is +`task-sequence-client-5.00.test-v1`, restricted to the synthetic +`5.00.TEST.` source version. It is a fixture contract, not a claim about a live +ConfigMgr build. + +An exact synthetic transaction key contains all of: + +1. `executionId`; +2. `taskSequencePackageId`; +3. `advertisementId`; and +4. `runContext`. + +Every field must be present in the transaction's cited evidence under the +recognized profile. All four fields must co-occur in each cited complete CCM +record used to assemble the transaction; values cannot be pooled across +records. Filename, path, timestamp, display name, component, or ingestion order +are forbidden join fields. + +The `unrelated-runs` scenario gives two records the exact same normalized +timestamp. Their exact execution IDs, advertisement IDs, run contexts, +artifacts, and transactions stay separate. The +`complete-looking-unkeyed` scenario contains a success-looking terminal line +but lacks the exact key. It remains a low-confidence, non-correlatable, +source-local observation and cannot create a successful transaction. + +The `unknown-profile` scenario contains key-looking fields under an +unrecognized source version. Those fields remain a low-confidence candidate; +they cannot be promoted by resemblance to the synthetic reviewed profile. + +## Phase and terminal semantics + +The proposed deterministic phase chain is: + +```text +start -> preflight -> diskOrImage -> setupWindows -> installClient + -> installSoftware -> postAction -> complete +``` + +A phase advances only on complete, profile-recognized evidence for the same +exact execution key. Expected states distinguish `inProgress`, +`blockedOrDeferred`, `failed`, and `succeeded`. + +`confirmedFailure` requires a cited terminal record for the same transaction. +This requirement is pinned independently for: + +- terminal preflight failure; +- disk/image failure; +- client-install failure; and +- software-install failure. + +A reboot request with expected continuation is `blockedOrDeferred`, not +failure. An in-progress record is not treated as a terminal record merely +because no later fragment was collected. Each nonterminal scenario names the +`client-task-sequence-smsts` logical artifact and the smallest bounded path +class to collect: `winpe`, `setup`, `fullOs`, `client`, or `unknown`. + +## Logical CCM records and rotation + +Each complete synthetic file passes through the existing raw CCM grammar and +the shared SCCM normalization layer. Timestamp provenance in expected output +is derived from one complete cited CCM record. The invalid-offset scenario +retains `offsetInvalid`, the observed `9999` offset, and no normalized UTC +value; it cannot be ordered by a fabricated timestamp. + +The rotation scenario stores one logical record as two physical fragments: +the archived `smsts.lo_` prefix and current `smsts.log` suffix. Each physical +fragment is deliberately incomplete and normalizes to no logical record by +itself. A controlled test-only archived-to-current concatenation produces +exactly one CCM record. + +The two physical artifacts retain distinct IDs and paths, the same path +fingerprint, explicit rotation kinds, and `partial` logical coverage. The +archived prefix contains and independently cites `_SMSTSLogPath`; the current +suffix declares its per-artifact `smstsLogPathEvidence` as null because that +token is not present in the suffix. + +The expected contract models the test-only logical reconstruction explicitly: +`logicalReconstructions` orders the archived artifact before the current +artifact and cites the archived line where `_SMSTSLogPath` is physically +observed. Both artifacts must share the declared sanitized capture path, +class, fingerprint, version, and relocation ordinal, and the ordered +concatenation must produce exactly one CCM record. The shared fingerprint +alone carries no path provenance. Until the final intake interfaces define +production logical reconstruction, both fragments remain partial, +low-confidence, non-correlatable source-local observations. + +## Coverage semantics + +Physical capture, logical-record framing, logical coverage, and execution state +are independent: + +- manifest `captureState: captured` means the physical artifact bytes are + available, whether or not those bytes form a complete logical CCM record; +- `rotation.fragmentComplete` states whether that physical artifact contains a + complete logical CCM record; +- logical coverage is `captured` when complete evidence is available, + `partial` when only incomplete rotation fragments are available, and + `absent` when no physical artifact was captured; and +- execution state is derived only from cited, profile-recognized records. + +The `incomplete` scenario contains one absent logical artifact and no physical +evidence. Its only conclusion is `insufficientEvidence` plus a bounded request +for the active Task Sequence log. Missing `smsts` evidence is a coverage gap; +it is not proof that no Task Sequence ran. + +No coverage gap is converted into application, policy, distribution-point, +management-point, or other server causality. Cross-side correlation is outside +this preparation slice. + +## Scenario matrix + +| Scenario | Path/identity purpose | Expected phase or disposition | +| --- | --- | --- | +| `winpe` | Before-format WinPE source | `preflight`, in progress | +| `post-format` | After-format WinPE relocation | `diskOrImage`, in progress | +| `pre-client` | New OS before client install | `setupWindows`, deferred | +| `client-installed` | Client path before terminal completion | `installClient`, in progress | +| `completed` | Final relocated keyed record | `complete`, succeeded | +| `relocated-fragments` | Same exact execution across four paths | Ordered through `complete` | +| `unrelated-runs` | Same-time adversarial executions | Two distinct transactions | +| `rotation-boundary` | One logical CCM record across two physical fragments | Partial, source-local only | +| `incomplete` | No captured `smsts` artifact | Coverage gap only | +| `terminal-preflight` | Explicit terminal record | Confirmed `preflight` failure | +| `disk-image-failure` | Explicit terminal record | Confirmed `diskOrImage` failure | +| `client-install-failure` | Explicit terminal record | Confirmed `installClient` failure | +| `software-install-failure` | Explicit terminal record | Confirmed `installSoftware` failure | +| `reboot-continuation` | Reboot with continuation expected | `postAction`, deferred | +| `invalid-offset` | Complete keyed CCM record with unusable offset | Phase retained; ordering unknown | +| `unknown-profile` | Key-looking fields under an unknown version | Low source-local candidate | +| `complete-looking-unkeyed` | Terminal-looking line without exact key | Low source-local observation | + +The corpus has 17 scenarios, 22 artifacts, and 21 evidence files totaling +exactly 8,243 bytes and 21 logical file lines. Across the 22 physical artifact +rows, manifest capture states are 21 captured and one absent; of those +captured rows, 19 contain complete logical CCM records and two are partial +rotation fragments. Across the 17 scenario-level logical coverage rows, 15 are +captured, one is partial, and one is absent. The +path-and-artifact-qualified evidence content digest is SHA-256 +`917df82bdf96ae4debd3e02e669669a9b564e932d7052091fb39094305593c8b`. + +The Rust contract hashes every physical file, builds rows as +`scenario NUL artifactId NUL relativePath NUL fileSha256 LF`, sorts the complete +row byte sequences lexicographically, concatenates them without another +separator, and hashes that byte stream. Scenario names, artifact IDs, and +relative paths are UTF-8; repository-relative paths use `/` regardless of host +path syntax. Each `fileSha256` is lowercase hexadecimal SHA-256 of the exact +checked-in evidence bytes. Evidence files use LF line endings, and line endings +are not normalized before hashing, so a CRLF rewrite changes the digest. Row +fields and the final digest row stream are UTF-8 bytes, NUL is byte `0x00`, LF +is byte `0x0a`, and the published aggregate digest is lowercase hexadecimal. +This binds scenario, physical identity, safe path, and bytes. It also pins +unique manifest references, exact byte counts, and the absence of orphaned or +aliased evidence files. + +## Determinism and fail-closed checks + +The runnable contract derives manifest coverage rather than trusting expected +output, binds provenance back to physical artifacts, normalizes CCM +timestamps, and verifies exact keys against cited lines. It requires sorted, +unique transaction, observation, finding, and provenance IDs. + +Adversarial mutations prove the contract rejects: + +- expected output that upgrades absent coverage to captured; +- a declared exact key assembled from fields in two unrelated complete records; +- an execution ID not present in the cited evidence; +- a normalized timestamp not produced by the cited CCM record; +- two artifact IDs that alias one physical evidence path; +- one rotation fragment borrowing `_SMSTSLogPath` from another artifact or a + same-fingerprint donor; +- escalation of an unkeyed observation above low confidence; and +- a confirmed failure with no terminal citation. + +The source-local ceiling and forbidden join rules mean a plausible name or +time cannot fill an identity gap. + +## Privacy and acceptance limits + +All paths, IDs, versions, messages, phases, times, and codes are deterministic +synthetic values. The corpus contains no customer name, real user profile, +SID, email, token, certificate, tenant, device serial, or copied production +log text. + +This is parser-side preparation only. It does not claim native Windows +collection, live ConfigMgr compatibility, task execution on a Windows client, +or SCCM lab acceptance. Passing this corpus is not an issue-closure condition. + +## References + +- [Microsoft: About log files in Configuration Manager](https://learn.microsoft.com/en-us/intune/configmgr/core/plan-design/hierarchy/about-log-files) +- [Microsoft: Task sequence variables](https://learn.microsoft.com/en-us/intune/configmgr/osd/understand/task-sequence-variables) +- [Microsoft: Using task sequence variables](https://learn.microsoft.com/en-us/intune/configmgr/osd/understand/using-task-sequence-variables) + +## Replay gates + +Run the checked-in preparation contract: + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_task_sequence_fixture_contract +``` + +That target validates the exact inventory/digest, physical storage, +manifest-derived coverage, CCM logical completeness, controlled rotation join, +path relocation, exact-key binding, same-time separation, timestamp +provenance, phase/terminal semantics, confidence ceilings, safe paths, and +privacy. + +Before implementation is merged against the final #318/#319 interfaces, also +run: + +```bash +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +npx tsc --noEmit +rustfmt --edition 2021 --check \ + crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +git diff --check +``` + +The future implementation must first map these preparation labels to the +reviewed #318/#319 contracts and request a false-causality review. It must not +add native-acceptance or server-causality claims based on these fixtures. diff --git a/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md new file mode 100644 index 000000000..d5f5ff0a1 --- /dev/null +++ b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md @@ -0,0 +1,211 @@ +# Issue #325 preparation: inventory, compliance, and metering + +Status: `proposedPending318And319` + +This slice prepares the source, manifest, fixture, and reducer-test contract for +issue #325. It intentionally does not add production catalog entries, native +capture, fact extractors, reducers, findings, or public model changes. +Production work remains dependent on reviewed, stable contracts from #318 and +#319. + +The fixtures are synthetic and sanitized. They prove only that this proposed +contract is deterministic and adversarially guarded. They are not evidence of +live Windows acceptance, ConfigMgr-version support, or observed production +message grammar. + +## Three independent workflow contracts + +| Workflow | Proposed logical group | Candidate sources | State chain | Exact proposed key | +| --- | --- | --- | --- | --- | +| Inventory | `client-inventory` | `InventoryAgent.log`, `InventoryProvider.log`, and `InventoryAgentProvider.log` when observed | Collect -> Provider -> Serialize -> Queue -> Report | inventory cycle ID + resource handle + report ID | +| Compliance | `client-compliance` | `CIAgent.log`, `CITaskMgr.log`, `DCMAgent.log`, `DCMReporting.log`, and `StateMessage.log` when observed | Evaluate -> Remediate -> Report | CI ID + baseline ID + state ID + resource handle | +| Metering | `client-metering` | `SWMTRReportGen.log`; additional names require separately observed evidence | Collect -> Aggregate -> Report | metering cycle ID + rule ID + report ID + resource handle | + +The names above are preparation candidates, not production admission. A later +catalog change must be table-driven and backed by sanitized source evidence plus +a reviewed extraction profile. Generic message keyword scanning is prohibited. + +Each proposed exact key is accepted only when every field co-occurs exactly once +in one complete, unambiguous CCM logical envelope. Required key and semantic +fields cannot be duplicated or conflict, and phase/disposition/terminal/result +semantics must come from the same source record. A field borrowed from another +envelope, line, artifact, root, rotation, or workflow cannot complete a key. +The structured CCM vocabulary is family-closed, and each admitted source owns +only its reviewed phases; inventory cannot borrow compliance evaluation +semantics to synthesize a predecessor. +The profile identifiers in this corpus are deliberately test-only: + +- `sccm-client-inventory-5.00.test-v1` +- `sccm-client-compliance-5.00.test-v1` +- `sccm-client-metering-5.00.test-v1` + +Source versions are bounded canonical tokens before any prefix-based profile +selection. An unknown source version has no fallback profile. It remains a +source-local, low-confidence observation and a coverage/profile gap. + +## Fixture matrix + +The fixture root is +`crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering`. +It contains 20 scenarios, 54 manifest artifacts, and 42 physical evidence files +(16,814 bytes). The deterministic fixture digest is `26c8cf8aee0741a2`. + +| Family | Scenarios | Contract coverage | +| --- | --- | --- | +| Inventory | `success`, `terminal-failures`, `recovery-contradictory`, `coverage-states`, `rotation-boundary`, `same-minute-collision` | all five phases; exact-key recovery; contradictory terminal records; missing/access-denied/capped/skipped/unsupported/malformed/partial sources; split rotations; two same-minute cross-root records | +| Compliance | `success`, `noncompliant-result`, `remediation-success`, `terminal-failures`, `recovery-contradictory`, `coverage-states`, `malformed-unknown-profile-invalid-offset`, `same-minute-collision` | compliant and noncompliant evaluation results; remediation; all three terminal phases; recovery and contradiction; every coverage state; unknown profile; unusable offset; same-minute records with different exact keys | +| Metering | `success`, `terminal-failures`, `recovery-contradictory`, `coverage-states`, `rotation-boundary`, `same-minute-collision` | collect/aggregate/report; exact-key recovery; contradiction; every coverage state; split rotations; two same-minute cross-root records | + +`noncompliant-result` is an evaluation result, not a confirmed failure. +Compliance evaluation, remediation, and report are separate phases. Inventory +queue/report failures never become compliance failures, and metering facts never +borrow CI/baseline/state identifiers. + +## Proposed additive manifest contract + +Every scenario has a `manifest.json` and `expected.json`. + +The manifest is SCCM-specific preparation data and does not overload generic +`ArtifactStatus` semantics. It preserves: + +- a synthetic bundle ID, client role, sanitized capture host, and site code; +- exact, bounded, control-free artifact identity scoped to its family/scenario + and logical workflow membership; +- original basename, sanitized attempted source path, and path fingerprint; +- current or `.lo` rotation identity and fragment completeness; +- explicit `captured`, `absent`, `accessDenied`, `capped`, `skipped`, + `unsupported`, or `parseFailed` capture state; +- source version, UTF-8 encoding, byte cap state, copied byte count, and safe + relative evidence path. + +`captured` plus `fragmentComplete: false` projects to `partial` coverage. +Nonphysical states have no evidence path and zero copied bytes. An absent source +does not invent path, fingerprint, or version identity. Duplicate basenames from +different roots remain separate when their sanitized paths, fingerprints, and +relative paths are distinct. One `(captureHost, sanitizedSourcePath, rotation)` +tuple cannot be declared as contradictory capture states, and each synthetic +root label must match its path fingerprint and physical relative path. +`captured`/`parseFailed` rows have an unapplied cap and no truncation field; +only `capped` rows may carry an applied cap plus truncation. Nonphysical rows +cannot invent encoding, cap, or truncation provenance. + +The proposed #319 preparation schema keeps +`rotation: {"kind": "current", "fragmentComplete": false}` on noncapture rows. +Here `false` is a compatibility marker meaning that no complete fragment was +captured; it does not assert that a partial physical fragment exists. This +corpus keeps that shape until #318/#319 publish the final additive manifest +contract instead of inventing a workflow-local variant. + +The expected contract keeps output deterministic and preparation-only: + +- every coverage row is an exact artifact-level projection of the manifest; +- every transaction is bound to one unique workflow/profile/exact-key identity; +- every evidence reference names a manifest artifact and valid line range, and + cited physical lines are globally unique and non-overlapping; manifest + artifacts plus output arrays use canonical stable ordering; +- transaction citations contain complete raw CCM records whose additive SCCM + timestamp provenance normalizes to UTC no later than the artifact's canonical + `capturedUtc`; +- `lastSuccessfulPhase` is present only when cited exact-key terminal + success/evaluation evidence supports it; a failure-only citation cannot + synthesize a predecessor phase; +- a confirmed failure requests exactly the bounded next artifact for its phase; +- successful, recovering, evaluative, and symptom-only transactions do not + invent next-artifact requests; +- `findings` remains empty until production reducers are authorized; +- source-local observations use a closed kind/artifact/claim schema, have a low + confidence ceiling, are not correlation eligible, and cannot repeat one + `(kind, artifact)` membership under another observation ID; +- `rotationSplit` requires `current` and `.lo` partial artifacts from one + synthetic root, canonical basename, source version, and exact family key; +- next-artifact requests name one admitted logical group and basename, never an + arbitrary path, drive, volume, wildcard, or recursive scan. + +## Conservative reducer expectations + +Future implementation may promote a preparation fact only after #318/#319 +review and an issue-scoped failing production test: + +- high-confidence success requires a complete, terminal phase record, an exact + family key, a selected source-version profile, and usable timestamp offset; +- confirmed failure requires an explicit terminal failure in the cited phase; +- recovery requires later terminal success with the same exact key and usable + ordering provenance; +- contradictory terminal evidence remains low confidence; +- missing, access-denied, capped, skipped, unsupported, malformed, partial, or + unknown-profile evidence remains coverage, not a workflow outcome; +- time proximity alone never joins transactions; +- client evidence alone never asserts a server-side cause. + +## Dynamic adversarial guards + +The fixture contract mutates valid scenarios at test time and requires rejection +of: + +- client-to-server role swaps and workflow/log-family source injection; +- unsafe relative paths, incorrect byte counts, and cross-root fingerprint + aliasing, root collapse, fingerprint swaps, or contradictory source + identities; +- capture-state schema drift such as applied caps on `captured` rows or retained + byte metadata on nonphysical rows; +- cross-family key fields, uncited key values, and phase borrowing from another + record; +- embedded/look-alike key labels that contain an expected label as a substring; +- duplicate/conflicting or unknown structured fields, nested CCM envelopes, + source-to-phase violations, and compliance result types borrowed from another + source record; +- blank, control-bearing, overlong, or foreign-scope artifact, transaction, and + observation identities; +- empty, control-bearing, whitespace-bearing, or malformed source-version + tokens before profile selection; +- duplicate source-local `(kind, artifact)` memberships and rotation splits + whose root, canonical basename, version, or exact key differs; +- overlapping or duplicate physical evidence-line identity; +- uncited predecessor `lastSuccessfulPhase` claims on confirmed failures; +- high-confidence output from an unknown source profile or invalid timestamp + offset; +- medium-confidence recovery from an unknown profile or unusable offset; +- recovery ordering through the additive SCCM timestamp envelope, including a + signless `+240` CCM offset whose legacy public projection is `Some(0)`; +- recovery promotion when additive timestamp provenance is missing or invalid; +- a cited complete record whose normalized timestamp is later than the + artifact's canonical capture time; +- phase-order claims that skip ahead, including a collect failure claiming that + report already succeeded; +- coverage and rotation observation kinds that do not match cited artifact + states, unknown output fields, noncanonical claims, or rewritten prohibited + claims; +- promotion of missing coverage to captured evidence; +- promotion of noncompliance to confirmed failure; +- duplicate exact transaction identities, collapsed same-minute root paths, and + same-minute key borrowing between distinct root artifacts; +- merging same-minute inventory and compliance terminal failures; +- reversed manifest, transaction, evidence, coverage, observation, or + observation-artifact arrays; +- missing, altered, or spurious next-artifact requests. + +The file projection also canonicalizes Windows `\` separators to manifest `/` +separators before comparing the physical evidence set. + +This mutation layer is independent of the positive fixture assertions, so an +internally consistent edit to both a manifest and its expected file cannot +silently weaken the safety contract. + +## Promotion gates and remaining blockers + +Production code must not be added from this branch. Promotion requires: + +1. #318 API review to publish stable evidence, coverage, signal, key, + redaction, and conservative finding contracts. +2. #319 API review to publish stable client manifest, collision, rotation, + access, cap, and native adapter contracts. +3. A source-evidence review for every basename and versioned grammar admitted + to the production catalog. +4. Focused RED then GREEN production tests for three separate fact extractors + and three separate reducers. +5. Parser, SCCM-spine, client-intake, wasm32, strict Clippy, formatting, and + `git diff --check` gates. +6. Native Windows capture/acceptance evidence before any live-support claim. + +The SCCM Server lab is a future native validation source and is not a blocker +for this pure-Rust preparation slice. diff --git a/docs/sccm/preparation/issue-326-client-management-corpus.md b/docs/sccm/preparation/issue-326-client-management-corpus.md new file mode 100644 index 000000000..037afff7f --- /dev/null +++ b/docs/sccm/preparation/issue-326-client-management-corpus.md @@ -0,0 +1,252 @@ +# Issue #326 client-management corpus preparation + +## Scope and dependency boundary + +This slice prepares the source, ownership, evidence, coverage, and adversarial +contracts for co-management, scripts, client notification, and observational +Software Center diagnostics. It intentionally owns only: + +- this preparation document; +- `sccm_client_management_fixture_contract.rs`; and +- the synthetic corpus under `fixtures/sccm/client/management/`. + +It does **not** add a production reducer, shared SCCM model, source catalog, +native capture adapter, server fact, cross-side rule, UI, or Windows acceptance +claim. Every manifest and expected contract is +`proposedPending318And319`. Production implementation remains blocked on the +reviewed public contracts from #318 and #319. This branch must also be +restacked and revalidated after the currently active #318 shared-contract PR +lands. + +## Capability and ownership gate + +The proposed ownership result is resolved before an operational transaction: + +```text +SccmOwned | IntuneOwned | SharedOrTransitioning | UnknownOwnership +``` + +- `SccmOwned` and `IntuneOwned` require complete, profile-recognized, + explicit-offset `CoManagementHandler` evidence. +- `IntuneOwned` is an evidenced terminal handoff, never an Intune diagnosis. +- `SharedOrTransitioning` is medium-confidence and cannot emit an operational + failure. +- `UnknownOwnership` is low-confidence and either cites contradictory evidence + or names the bounded co-management coverage gap. +- Only `SccmOwned` permits a script or client-notification transaction in this + proposed corpus. + +Software Center remains an observational capability gate. The sanitized +`SCClient_SYNTHETIC_*.log` and `SCNotify_SYNTHETIC_*.log` names are test-only +placeholders for a redacted filename class. They are always +`candidateUnsupported` and `parserEligible: false`. Capturing such a candidate +does not admit its grammar or establish UI state, user intent, server +availability, or an action outcome. + +## Design-only source contract + +| Logical artifact | Exact synthetic basename | Preparation status | Semantic boundary | +| --- | --- | --- | --- | +| `client-co-management` | `CoManagementHandler.log` | admitted only by `sccm-client-co-management-5.00.test-v1` | workload ownership/handoff | +| `client-scripts` | `Scripts.log`, canonical `Scripts.lo_` rotation | admitted only by `sccm-client-scripts-5.00.test-v1` | Receive → Execute → Report | +| `client-notification` | `CcmNotificationAgent.log` | admitted only by `sccm-client-notification-5.00.test-v1` | Receive → DeferOrDispatch → Acknowledge | +| `client-software-center` | sanitized `SCClient_SYNTHETIC_1.log`, `SCClient_SYNTHETIC_2.log`, `SCNotify_SYNTHETIC_1.log` | candidate/unsupported; never parser eligible | physical capability/coverage observation only | + +No BGB or server log is admitted. A source alias, case-folded basename, broad +`*.log` match, or module-name resemblance does not enter the catalog. + +## Versioned keys and timestamp provenance + +The scripts proposal requires all three exact fields in every cited complete +logical record: + +```text +ScriptId + ExecutionId + ResourceHandle +``` + +The notification proposal likewise requires: + +```text +NotificationId + ChannelId + ResourceHandle +``` + +All values are bound to the named synthetic extraction profile. Handles use a +`safe:` representation. Filename, component, same-minute timing, physical +root, signal, display text, and ingestion order cannot create or merge a key. +A terminal record is high-confidence only when the exact key is co-located, +the source version matches the canonical `5.00.TEST.` plus four-decimal test +profile grammar, the CCM record is complete, and its additive SCCM timestamp +envelope has normalized UTC provenance. Signless +legacy offsets retain their SCCM interpretation; seven-digit fractional tails +remain offset-missing. Unknown profiles and unusable offsets stay source-local +and noncorrelatable. Every artifact has a canonical `capturedUtc`, and no cited +normalized record may postdate its capture. + +Structured fields are parsed as exact, unique, record-local names under the +workflow source contract. Substring lookalikes, conflicting duplicate fields, +nested CCM envelopes, and fields borrowed from another record cannot satisfy a +key, phase, ownership, disposition, or terminal assertion. + +Raw command arguments and user context are not present. The corpus uses only +`CommandContextHandle` and `UserContextHandle` values under the `safe:` +boundary. + +## Coverage and physical provenance + +The preparation manifest is additive and does not reuse generic +`ArtifactStatus` semantics. Each artifact preserves: + +- exact client role and logical source group; +- exact source/capability admission state; +- canonical capture-attempt time; +- sanitized source path bound to the exact scenario, client role, logical + source group, and closed fixture layout, plus a lowercase opaque + `safe:path:326:` fingerprint when a candidate path was observed; +- case-normalized physical source identity uniqueness enforced independently + of self-declared fingerprints; +- unique bundle-relative path for physical bytes; +- explicit current versus `.lo_` rotation and fragment completeness; +- collection-cap provenance for capped bytes; +- source version for captured bytes; and +- `captured`, `partial`, `capped`, `absent`, `accessDenied`, `malformed`, or + `unsupported` effective coverage. + +Raw `captureState: parseFailed` maps only to effective `malformed` coverage; it +never becomes `captured`. + +Every non-complete artifact is surfaced by a low-confidence source-local +observation. It cannot prove success, failure, ownership, delivery, or +nonexistence. + +## Scenario matrix + +| Scenario | Workflow | Contract outcome | +| --- | --- | --- | +| `co-management-intune-owned` | co-management | exact terminal Intune handoff; no SCCM/Intune failure | +| `co-management-sccm-owned` | co-management | exact terminal SCCM ownership | +| `co-management-transitioning` | co-management | explicit transitioning state; medium confidence | +| `co-management-unknown` | co-management | absent evidence becomes an ownership coverage gap | +| `script-success` | scripts | Receive → Execute → terminal Report for one exact key | +| `script-failure` | scripts | terminal Execute failure after cited Receive success | +| `script-incomplete` | scripts | capped current plus incomplete `.lo_` fragments stay separate | +| `script-intune-handoff` | scripts | unkeyed SCCM error remains local after exact Intune handoff | +| `notification-received` | notification | Receive → terminal Acknowledge for one exact key | +| `notification-deferred` | notification | explicit defer is not failure and requests one bounded continuation | +| `notification-failure` | notification | terminal Acknowledge failure after cited Receive success | +| `software-center-observed` | Software Center | captured candidate remains unsupported/parser-ineligible | +| `software-center-insufficient` | Software Center | absent, malformed, unsupported, and unknown-ownership gaps | +| `mixed-unrelated` | mixed adversarial | same-time roots, conflicting ownership, access denial, unknown profile, and invalid offsets remain unlinked | + +The corpus is pinned at 14 scenarios, 30 artifacts, and 25 physical evidence +files totaling 8,648 bytes. Raw capture-state inventory is 23 captured, three +absent, one capped, one access-denied, one parse-failed, and one unsupported. +The FNV-1a-64 digest over sorted +`scenario NUL artifactId NUL relativePath NUL hex(evidence bytes) LF` rows is: + +```text +409619f730304018 +``` + +The digest binds physical identity, path, and exact synthetic bytes. It is not +a cryptographic authenticity claim. + +## Adversarial contract + +The focused Rust target dynamically proves that the validator rejects: + +- client artifacts relabeled as server role; +- case-folded or invented source aliases; +- raw Windows paths, cross-workflow evidence roots, server-shaped sanitized + paths, identity-bearing synthetic paths/fingerprints, duplicate sanitized + physical identities, and aliased cross-root path fingerprints; +- borrowed exact transaction keys; +- substring field lookalikes, conflicting duplicate fields, and nested CCM + envelopes; +- contradictory ownership and ownership borrowed across workflows; +- unversioned profile aliases, malformed in-prefix versions, and unknown-version + promotion; +- capped coverage relabeled captured; +- invalid/signless/missing-offset evidence promoted to high confidence and + capture times earlier than cited evidence; +- parse-failed evidence relabeled as a generic coverage gap; +- terminal phases moved before receipt, ownership observed after an operational + transaction, distinct phases assigned the same ambiguous timestamp, and + required intermediate script phases omitted; +- missing scenario transactions, duplicate exact transaction keys, and + out-of-order transactions; +- unknown ownership/transaction semantic fields and unsupported causal phrasing + using `because`, `resulted in`, or `responsible for`; +- a coherent attempt to mark Software Center candidates admitted and parser + eligible; +- a server-causal claim in client-only source-local output; and +- a fabricated SCCM operational transaction after an exact Intune handoff. + +The checked-in `mixed-unrelated` case additionally proves that two +same-basename `Scripts.log` artifacts from different roots retain distinct +paths and fingerprints. Their same-minute, unkeyed success/error records do +not combine. Exact-looking notification evidence with an invalid offset also +cannot become a high-confidence transaction. + +## TDD record + +The first focused run was intentionally red: + +```text +cargo test --locked -p cmtraceopen-parser --test sccm_client_management_fixture_contract +1 failed: management fixture corpus did not exist +``` + +After the smallest corpus/validator was green, the dynamic adversarial target +was added. That second red run reported 7 passed / 2 failed and exposed six +accepted fabrications: server role alias, source alias, raw path, fingerprint +collision, coherent unsupported-source promotion, and server-causal text. +The validator was then hardened at those exact boundaries. + +CodeRabbit review exposed a third red boundary: reversed terminal phases, late +ownership evidence, and equal timestamps for distinct phases were all +accepted. The evidence envelope now retains parsed UTC milliseconds, phase +progression must be strictly chronological, and cited SCCM ownership must +strictly precede the first operational event. + +Independent exact-head review of PR #373 exposed a fourth red boundary. The +permanent Rust 1.88 run executed 17 tests: the 11 prior tests passed while six +new adversarial groups failed with all 19 reviewed mutations accepted. The +correction now consumes #318 `normalize_ccm_artifact` timestamp provenance, +closes record/JSON field sets, binds paths and ownership to workflow source +groups, requires exact scenario transaction cardinality and unique keys, and +preserves malformed coverage semantics. The same focused target is green at +17/17 only after those structural corrections. + +A subsequent independent review exposed four more physical-provenance +bypasses: a malformed in-prefix source version, identity-bearing material +inside a synthetic source path or fingerprint, and duplicate sanitized source +identities hidden behind different fingerprints. The permanent contract now +rejects all four while retaining the bounded synthetic corpus. + +## Replay and acceptance limits + +Run the preparation target: + +```bash +cargo +1.88.0 test --locked -p cmtraceopen-parser --test sccm_client_management_fixture_contract +``` + +Before review, also run: + +```bash +cargo +1.88.0 test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo +1.88.0 test --locked -p cmtraceopen-parser --test sccm_client_intake_fixture_contract +cargo +1.88.0 test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo +1.88.0 check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +npx tsc --noEmit +rustfmt +1.88.0 --edition 2021 --check crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +git diff --check +``` + +This command set checks the deterministic pure-Rust fixture contracts, the +package-wide parser suite, strict linting, TypeScript type checking, +formatting/whitespace, and wasm32 compilation. It does not exercise native +candidate discovery, permissions, Windows layout, ConfigMgr version, Software +Center filename classes, notification transport, Intune behavior, or live SCCM +acceptance. Passing this preparation corpus is not an issue-closure condition. diff --git a/docs/sccm/preparation/issue-327-server-site-core-corpus.md b/docs/sccm/preparation/issue-327-server-site-core-corpus.md new file mode 100644 index 000000000..9f5ec5074 --- /dev/null +++ b/docs/sccm/preparation/issue-327-server-site-core-corpus.md @@ -0,0 +1,241 @@ +# Issue #327 server site-core/status corpus contract + +Status: preparation only + +This document freezes the synthetic scenario contract for issue #327 without +selecting speculative Rust interfaces. Production reducers remain blocked on +the reviewed #318 diagnostic spine and #335 server-intake contracts. The +fixtures define observable behavior that those later implementations must +satisfy. + +## Scope + +Issue #327 consumes only the catalogued `server-sitecomp` and `server-status` +source groups for an observed site-server role. It may describe site-core +component and status-processing evidence local to that role. + +It must not: + +- infer client impact; +- infer that a downstream Management Point, Distribution Point, SUP, WSUS, + Provider, or other role is absent or unhealthy; +- create a transaction from a component name, source basename, host name, site + code, or timestamp alone; +- turn an absent, access-denied, capped, skipped, unsupported, malformed, or + partial source into a failure fact; +- join a logical CCM record across rotation files; or +- treat an error-looking record without a profile-recognized terminal status + as `ConfirmedFailure`. + +## State contract + +```text +ComponentStart + -> ComponentWork + -> InboxOrQueue + -> StatusOrStateProcessing + -> HealthyOrTerminal +``` + +Phases advance only from complete logical records admitted by the versioned +profile. A positive fact entering a phase can become the last successful +phase. An error observed while attempting a phase does not make that phase a +success. Consequently: + +- a terminal component failure after `ComponentWork` (serialized + `componentWork`) leaves `componentWork` as the last success; +- a terminal status-processing failure after a positive processing-start fact + leaves `StatusOrStateProcessing` (serialized `statusOrStateProcessing`) as + the last success; +- a profile-recognized `SC_INBOX_BACKLOG` leaves `ComponentWork` (serialized + `componentWork`) as the last success and deterministically yields + `BlockedOrDeferred`; it remains non-terminal and never becomes a root-cause + finding; +- a later recovery reaches `HealthyOrTerminal` (serialized + `healthyOrTerminal`) only for the same exact transaction key and usable + source-local ordering; and +- a split or malformed rotation contributes a parse/coverage gap, never a + phase or terminal fact. + +## Versioned identity and signal admission + +The fixture corpus names the experimental extraction profile +`sccm-site-core` version `1`. The profile is deliberately synthetic; it is not +evidence that these message patterns are accepted against a live ConfigMgr +version. + +A transaction key is the exact tuple: + +```text +(profile id, profile version, siteCode, component id, work-item id) +``` + +`siteCode` is the manifest field’s privacy-safe synthetic site identifier; the +key does not substitute an inferred path, role, or display label. The profile +must validate both the component ID and the status ID before a fact can advance +the state machine. Version 1 admits these synthetic component IDs: + +- `SMS_EXECUTIVE` +- `SMS_DISTRIBUTION_MANAGER` + +Version 1 admits these synthetic status IDs: + +- `SC_COMPONENT_START_OK` +- `SC_COMPONENT_WORK_OK` +- `SC_INBOX_ACCEPTED` +- `SC_INBOX_BACKLOG` +- `SC_STATUS_PROCESSING_OK` +- `SC_COMPONENT_HEALTHY` +- `SC_COMPONENT_TERMINAL_FAILURE` +- `SC_STATUS_TERMINAL_FAILURE` +- `SC_COMPONENT_RECOVERED` + +An unknown component, status ID, profile ID, or profile version is retained as +an unlinked raw-safe observation. At most it can support a low-confidence +`Symptom`; it cannot create a keyed transaction or high-confidence terminal +result. + +## Terminality, recovery, and confidence + +`ConfirmedFailure` with `High` confidence requires a complete, +profile-recognized, source-specific terminal fact with the exact transaction +key. `SC_INBOX_BACKLOG` is always `BlockedOrDeferred`, never terminal or a +root-cause finding. Low-confidence `Symptom` is reserved for generic or +unrecognized errors. Missing downstream evidence is non-terminal. + +Recovery requires all of the following: + +1. the earlier failure and later success use the same profile ID and version; +2. site, component, and work-item keys match exactly; +3. the later record is the profile-recognized recovery or healthy terminal + status; +4. timestamp provenance permits source-local ordering; and +5. both records are complete logical records. + +A healthy record for another component in the same minute cannot recover, +qualify, suppress, or merge with a failing component transaction. + +## Manifest draft boundaries + +Each scenario `manifest.json` follows the plan's additive SCCM server manifest +shape: + +- `sccmManifestVersion` is `1`; +- `bundleRole` is `server`; +- topology records only synthetic capture host, observed role, and site code; +- every `artifactId` is non-empty and unique within its scenario + manifest/bundle. It is authoritative for physical evidence, coverage + references, and deterministic artifact ordering; +- artifacts retain artifact ID, role, source group/kind, redacted original + path, basename, configured-path observation, rotation, capture state, + synthetic source version, collection time, encoding, relative path, and + copied bytes; +- `captured` and `capped` artifacts have a non-null relative path and exact + local evidence; +- every referenced physical artifact whose expected evidence identifies an + incomplete logical record declares `rotation.fragmentComplete: false`, + whether its capture state is `captured` or `capped`; capture success never + implies parse completeness; +- `absent`, `accessDenied`, `skipped`, `unsupported`, and `parseFailed` + artifacts have no relative path, zero copied bytes, and no physical + line-ranged evidence; and +- artifacts are sorted by `artifactId`. + +This preparation corpus does not make the manifest fields a public Rust API. +#335 owns that decision and must either map this draft losslessly or document a +reviewed fixture migration. + +## Expected-result contract + +Each `expected.json` uses `expectedContractVersion: 1` and records: + +- the exact profile; +- zero or more component-keyed results; +- state and last-success semantics; +- `findingClass` and confidence ceiling; +- exact physical evidence references using artifact ID plus physical line + range; +- explicit coverage-only references that may contain only the artifact ID; +- an exact, bounded next-artifact request or an empty request list; +- unlinked observations where applicable; +- deterministic result/evidence/request ordering; and +- prohibited client-impact, absent-role, and cross-side causal claims. + +Evidence references never use a basename as artifact identity. Every physical +reference contains its own manifest `artifactId` plus exact `lineStart` and +`lineEnd`; logical entry IDs use the fixture-stable form +`:-`. Coverage-only references may stop at the +manifest `artifactId`. An artifact whose manifest `captureState` is `absent`, +`accessDenied`, `skipped`, `unsupported`, or `parseFailed` cannot carry +physical lines. A physically present capped or malformed fragment may be cited +by exact lines inside a coverage gap, but it remains coverage/nonterminal +evidence. + +## Scenario matrix + +| Scenario | Required behavior | Maximum diagnosis | +| --- | --- | --- | +| `healthy` | All five phases complete for one exact component/work item. | Healthy result; no finding. | +| `component-failure` | Recognized terminal component failure after work; status source absent is a coverage fact only. | `ConfirmedFailure` / `High`, last success `ComponentWork`. | +| `inbox-backlog` | Recognized queue backlog without terminal evidence; status source absent. | `BlockedOrDeferred` / `Low`, never root cause. | +| `status-processing-failure` | Positive processing start then recognized status terminal failure for the same key. | `ConfirmedFailure` / `High`, last success `StatusOrStateProcessing`. | +| `recovery` | Recognized failure followed by a later recognized recovery for the exact same key. | Historical `Symptom` / `High`; no current confirmed failure. | +| `contradictory` | One component fails while an independent component succeeds in the same minute. | Two independent results; no cross-component merge or recovery. | +| `malformed` | A terminal-looking status candidate is an unclosed logical record, so its visible component/work-item/status tokens are not admitted. | `Symptom` / `Low` plus parse coverage; no transaction, key, phase, or terminal state. | +| `rotation-boundary` | Opening and closing fragments are split across `.lo_` and current files. | `InsufficientEvidence` / `None`; no phase or terminal fact. | +| `incomplete` | Site-component source is capped, status source is access denied, state source is absent. | `InsufficientEvidence` / `None`; coverage only. | + +## Minimal bounded requests + +Requests use a catalog logical source name, the `siteServer` role, declared +basenames, declared rotations, and the exact component/work-item scope when +available. No fixture requests a drive, arbitrary directory, unrestricted IIS +tree, database, registry, WMI, event log, network query, or live collection. + +## Synthetic and privacy rules + +Every complete evidence file begins with a profile-validated semantic CCM +record whose message starts `# SYNTHETIC FIXTURE - NOT LIVE DATA` and then +contains the actual profile/component/work-item/status tokens. There is no +separate marker-only record, because unknown raw records must be preserved as +symptoms and would make the fixture non-minimal. Fixture identifiers use only +`LAB-CM01`, site code `LAB`, `SMS_*` synthetic component IDs, and `SC-*` +synthetic work-item IDs. Paths in manifests are `REDACTED`; evidence contains +no customer host, user, domain, URL, certificate, database, package, client, +credential, or live log content. + +The marker prefix does not replace or suppress the first semantic signal. The +standalone malformed scenario puts the marker inside its unclosed candidate, +sets `rotation.fragmentComplete: false`, and requests exactly one fresh +`statmgr.log` current artifact. Visible key and terminal-looking tokens inside +that incomplete candidate remain unadmitted. The rotation-boundary exception +marks each manifest artifact with `syntheticFixture: true` and +`rotation.fragmentComplete: false`, puts the literal marker inside the opening +malformed fragment, and leaves the closing-fragment file untouched by any +artificial comment or complete marker record. Reducers must not concatenate +those physical rotation files; expected coverage names both unique physical +artifact IDs. + +## Future reducer assertions + +When #318 and #335 are reviewed, the production test for each scenario must: + +1. deserialize and normalize the SCCM-specific manifest without changing + generic collection-manifest semantics; +2. parse only complete CCM logical records; +3. admit only source/profile/component/status combinations listed above; +4. compare the serialized reducer result to `expected.json`; +5. rerun after reversing input artifact order and require byte-identical + normalized output; +6. prove manifest artifact IDs are unique within the scenario/bundle and are + the authoritative deterministic ordering key; +7. validate every physical evidence reference by exact artifact ID and line + range while permitting artifact-ID-only coverage references; +8. prove nonphysical capture states never carry physical evidence; +9. prove `ConfirmedFailure` / `High` always cites its terminal record; +10. prove coverage states do not become success or failure facts; +11. prove every expected evidence reference with + `completeLogicalRecord: false` maps to a manifest artifact with + `rotation.fragmentComplete: false`; and +12. prove no client-impact, downstream-role-absence, or cross-side causal claim + escapes the role-local analyzer. diff --git a/docs/sccm/preparation/issue-328-management-point-corpus.md b/docs/sccm/preparation/issue-328-management-point-corpus.md new file mode 100644 index 000000000..09ca707f5 --- /dev/null +++ b/docs/sccm/preparation/issue-328-management-point-corpus.md @@ -0,0 +1,299 @@ +# Issue #328 Management Point corpus preparation + +## Purpose and dependency boundary + +This document and its synthetic fixtures prepare Task 4 of the SCCM Server +intake/core plan. They define the behavior required from a future +Management Point analyzer without implementing a reducer, native adapter, +parser family, or public wire schema. + +The preparation contract is explicitly `proposedPending318And335`: + +- #318 must publish the shared artifact, evidence, timestamp, key, phase, + finding, coverage, request, redaction, and confidence contracts. +- #335 must publish the role-aware server catalog, physical artifact identity, + topology manifest, tolerant reader, and deterministic coverage projection. +- #327 may later contribute independently cited site-core context, but #328 + must remain callable without consuming #327 output. +- #333 may later consume counterpart-ready #321/#328 facts. This corpus does + not correlate a client and server or make a client-side causal claim. + +Every field in `manifest.json` or `expected.json` is therefore a review label, +not a speculative production interface. The future implementation must map +these behaviors onto the reviewed #318/#335 public types. + +## Role-local state contract + +```text +ReceiveRequest + -> Authenticate + -> RegisterOrIdentify + -> ResolveLocationOrPolicy + -> Respond + -> RecordOutcome +``` + +The chain is one role-local Management Point transaction. It is not a client +transaction and does not imply that a nearby client symptom reached this +server. + +- `ReceiveRequest` requires an explicit, profile-valid MP receive fact. +- `Authenticate` records a server-side authentication disposition. +- `RegisterOrIdentify` records the MP registration or identity disposition. +- `ResolveLocationOrPolicy` records a location or policy resolution fact. +- `Respond` records an explicit response attempt or terminal disposition. +- `RecordOutcome` records a coherent final server-side outcome. + +A last-success value is the latest coherent phase evidenced for the same exact +key and compatible topology. Filename, component, source proximity, or +timestamp proximity cannot advance the state. + +## Curated source contract + +| Catalog group | Physical producer and basename | Responsibility | +| --- | --- | --- | +| `server-mp-auth` | `MP_GetAuth` / `MP_GetAuth.log` | Receive and Authenticate | +| `server-mp-auth` | `MP_CliReg` / `MP_CliReg.log` | Register or identify | +| `server-mp-auth` | `MP_RegistrationManager` / `MP_RegistrationManager.log` | Registration disposition | +| `server-mp-policy` | `MP_Location` / `MP_Location.log` | Location resolution | +| `server-mp-policy` | `MP_GetPolicy` / `MP_GetPolicy.log` | Policy resolution, response, and outcome | +| `server-mp-policy` | `SMS_MP_CONTROL_MANAGER` / `mpcontrol.log` | Role-local MP context only; never a keyed request by itself | +| `server-mp-iis` | `IIS-W3C` / explicitly catalogued `u_ex*.log` | Optional supplemental request evidence; never an arbitrary IIS tree | + +All CCM sources reuse the existing CCM logical-record parser. The source +catalog and workflow analyzer must not add a Management Point `ParserKind` or +duplicate CCM framing. A catalogued IIS source uses the existing IIS W3C +parser and remains optional. + +The source producer, captured artifact basename, and CCM `file=` code-origin +attribute are three distinct provenance fields. None may be substituted for +another. + +Microsoft's [Configuration Manager log-file +contract](https://learn.microsoft.com/en-us/intune/configmgr/core/plan-design/hierarchy/about-log-files) +specifies that standard rollover replaces the active `.log` suffix with +`.lo_`. For example, `MP_GetAuth.log` rolls to `MP_GetAuth.lo_`; the rollover +is not named `MP_GetAuth.log.lo_`. A physical manifest must retain that +observed basename exactly. + +## Physical artifact, topology, and path provenance + +Every manifest represents a synthetic server bundle with: + +- `bundleRole: server` and `workflow: managementPoint`; +- observed role `managementPoint`; +- synthetic capture host `LAB-MP01`; +- synthetic site label `LAB`; +- public correlation-safe MP handle `safe:mp:lab-mp-01`; +- a corpus-unique physical `artifactId`; +- exactly one design-only catalog group membership; +- explicit role, producer, source kind, basename, and rotation lineage; +- configured/catalogued/optional path provenance plus an opaque path + fingerprint; +- an obvious `SYNTHETIC://` source handle for captured files; +- explicit capture state, ConfigMgr profile label, collection time, encoding, + byte limit, and exact copied byte count; and +- a collision-safe relative evidence path. + +`LAB` also satisfies Microsoft's [site-code +contract](https://learn.microsoft.com/en-us/intune/configmgr/core/servers/deploy/install/setup-wizard-central-primary): +an exact ConfigMgr site code is three alphanumeric characters from `A` through +`Z` and `0` through `9`. The profile validator applies `^[A-Z0-9]{3}$` to +every exact topology and transaction-key claim. + +`Captured`, `Absent`, `AccessDenied`, `Capped`, `Skipped`, `Unsupported`, and +`ParseFailed` remain distinct. A noncapture has zero bytes and no invented +encoding or collection-limit result. Its rotation kind and lineage retain the +candidate's deterministic identity, but `fragmentComplete` is omitted because +no physical fragment exists. Only a captured or capped physical artifact may +declare fragment completeness. + +The manifest records an observed MP role independently from source coverage. +An absent candidate or missing default path is a source gap only. It never +means the role is absent, uninstalled, unavailable, or unhealthy. Configured +non-default paths must survive through the same opaque path provenance rather +than being replaced by a failed default-path probe. + +## Synthetic evidence mechanics + +Complete CCM evidence is forced through the existing CCM grammar. The literal +`SYNTHETIC FIXTURE` appears inside the first semantic record of every complete +artifact and is never a marker-only line. Every record retains: + +- the physical artifact and exact line range; +- the declared producer/component; +- the distinct CCM `file=` code-origin value; +- original timestamp text and numeric offset; +- normalized UTC only when that offset is valid; and +- record-before-collection chronology. + +The two physical files in the rotation-boundary split have +`fragmentComplete: false`. Neither is a logical record, neither exposes a key +or authentication fact, and joining their text is not an analyzer behavior. +A separate complete record in that scenario uses an unknown synthetic version +and malformed request key. It remains keyless and cannot borrow a key from +either adjacent fragment. + +## Exact synthetic key profile + +The selected preparation profile is `mp-server-5.00.test-v1`, scoped only to +synthetic source versions beginning `5.00.TEST.` and to the declared +`server-mp-auth` and `server-mp-policy` families for the Management Point +role. It is not a claim about an observed production ConfigMgr build. + +Two phase-appropriate exact key shapes are allowed: + +1. `requestClientTopology`: exact request UUID, correlation-safe client + handle, site code, and MP host handle. `policyId` is explicitly null. +2. `requestPolicyClientTopology`: the same values plus an exact policy UUID. + +Every source fact admitted to an exact transaction repeats the complete +selected key shape. In the expected-output labels, `transaction.key` is the +single authoritative key and every contained observation declares +`observationKeyBinding.mode: inheritImmutableParentTransactionKey`. An +observation has no independent `key` field and cannot override, borrow, or +conflict with its immutable parent key. Its one cited source line must repeat +every parent-key token, so a phase fact remains counterpart-ready for Issue +`#333` without duplicating the serialized key. + +An assignment-looking token, raw client-looking token, message neighborhood, +same host label, component, or timestamp is insufficient. Unknown versions, +malformed keys, physical fragments, and incompatible client-style tokens +remain keyless source-local observations with: + +- `keyConfidence: none`; +- low confidence and a low confidence ceiling; +- `correlationEligible: false`; and +- `borrowedKeys: false`. + +## Reducer and causality rules + +1. Reduce one exact key and compatible MP topology at a time. +2. Preserve every observation and stable-sort artifacts, transactions, + observations, findings, context facts, and evidence references. +3. A deferred response is `blockedOrDeferred`, not a terminal failure. A later + same-key explicit success may complete the transaction when source ordering + is coherent. +4. Same-key success and failure at the same resolved instant remain + contradictory when no trusted ordering resolves them. Their confidence + ceiling is low. +5. An isolated contradictory control transaction cannot upgrade, qualify, or + overwrite a different exact key's terminal finding. +6. A source-specific terminal record plus coherent preceding same-key phases + may support a role-local confirmed failure. A red-looking record alone is a + symptom. +7. Missing coverage requests the smallest group: + `server-mp-auth` for Receive/Authenticate/Register and + `server-mp-policy` for Resolve/Respond/Outcome. +8. Missing optional `server-mp-iis` coverage never creates a failure or lowers + a coherent main-source result. +9. `mpcontrol.log` may add separately cited role-local context but cannot form + a request transaction. +10. Client-looking values or adjacent client timestamps cannot attach to a + server transaction. No client root cause or impact is emitted. + +## Exact scenario matrix + +The directory set remains exactly the nine scenarios prescribed by Task 4. +Program Gate C controls are isolated inside those scenarios rather than +creating unplanned top-level directories. + +| Scenario | Primary outcome | Last successful phase | Bounded next artifact | Embedded control | +| --- | --- | --- | --- | --- | +| `healthy-policy` | Successful policy response and recorded outcome | RecordOutcome | None | A same-key deferred Respond observation is followed by explicit success | +| `auth-failure` | Confirmed role-local authentication failure | ReceiveRequest | None | None | +| `registration-failure` | Confirmed role-local registration failure | Authenticate | None | None | +| `location-failure` | Confirmed role-local location-resolution failure | RegisterOrIdentify | None | None | +| `policy-failure` | Confirmed role-local response failure | ResolveLocationOrPolicy | None | A separate exact key retains a same-instant Respond contradiction at low confidence | +| `iis-supplemental` | Successful main-source policy response with optional IIS intentionally skipped | RecordOutcome | None | `mpcontrol` context cannot enter the request transaction | +| `unrelated-client-like-key` | No MP transaction; incompatible client-like values remain source-local | None | `server-mp-auth` | Time proximity and client-looking tokens are explicitly unused | +| `rotation-boundary` | No MP transaction; split fragments are coverage-only | None | `server-mp-auth` | Separate unknown-version malformed key remains non-correlatable | +| `incomplete` | Exact transaction stops after registration because MP policy coverage is absent | RegisterOrIdentify | `server-mp-policy` | Missing source does not imply a missing MP role | + +Together these fixtures cover the Program Gate C completed, confirmed +terminal, blocked/deferred, contradictory, incomplete, rotation, and malformed +classes while preserving the Task 4 directory contract. + +## Expected-output preparation labels + +Each `expected.json` declares: + +- the exact state chain and role-local reducer boundary; +- synthetic profile selection and phase-appropriate exact key shape; +- observed role/topology without a default-path inference; +- physical artifact provenance and source-group coverage; +- transactions and observations with deterministic IDs; +- immutable parent-transaction key inheritance for every exact observation, + with observation-level key fields and overrides forbidden; +- original timestamp, offset, normalized UTC, producer, and exact evidence + ranges; +- phase, state, last successful phase, classification, confidence, and + confidence ceiling; +- one cited finding per nonsuccess subject; +- a bounded next artifact or explicit null; +- keyless/non-correlatable local observations; +- deterministic reordered-input behavior; +- Program Gate C coverage tags; and +- prohibited role-absence, IIS-required, client-cause, time-only, and + cross-side claims. + +These are behavior labels for future compiled tests, not proposed final public +field names. + +## Privacy limits + +The corpus uses only deterministic synthetic values: `LAB-MP01`, `LAB`, +synthetic UUIDs, correlation-safe handles, and `SYNTHETIC://` path handles. +It contains no customer logs, real hosts, domains, users, SIDs, URLs, +certificates, database names, credentials, package identifiers, or raw client +identities. Error-looking values are synthetic terminal markers, not external +error-database diagnoses. + +## Focused validation and future test specification + +Preparation validation must fail before the document/corpus exists, then pass +only when the exact scenario set, byte/path/reference closure, privacy, +logical-record boundary, chronology, topology, producer, key, confidence, +coverage, Gate C, and false-causality contracts are satisfied. + +After #318 and #335 publish the reviewed types, create +`sccm_server_management_point.rs`, load these fixtures through the public +server reader, run only the independently callable MP analyzer, reverse and +shuffle inputs, and compare normalized serialized results. + +Run the plan-prescribed commands: + +```bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_management_point +cargo test --locked -p cmtraceopen-parser --test sccm_server_site_core +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +``` + +Repository policy also requires `npx tsc --noEmit`. Until #318/#335 land, the +two focused SCCM server test targets are expected blockers rather than +permission to invent a private interface. The aggregate parser, strict Clippy, +wasm32, TypeScript, JSON, exact-byte, forced-parser, and diff gates remain +meaningful for this preparation slice. + +Native Windows collection acceptance is explicitly pending. Future acceptance +must record the lab ConfigMgr version, observed role topology, configured path +provenance, capture time zone, synthetic scenario, byte limits, and redaction +procedure. macOS parser proof is not native role discovery or capture proof. + +## Issue #333 contractual handoff + +Issue `#328` may expose exact profile-qualified request/policy keys, a +correlation-safe client handle, site and MP topology handles, role-local +phases, source ordering provenance, and evidence references. That is the +entire handoff. + +Issue `#333` must independently require compatible keys from Issue `#321`, +compatible role topology, usable timestamp offsets whenever ordering is +asserted, sufficient counterpart coverage, and terminal/corroborating facts. +A time-only, filename-only, error-code-only, client-ID-looking, or same-host +join is never a high-confidence cause. This corpus performs no link and makes +no client-side claim. diff --git a/docs/sccm/preparation/issue-329-distribution-point-corpus.md b/docs/sccm/preparation/issue-329-distribution-point-corpus.md new file mode 100644 index 000000000..f62c3ab96 --- /dev/null +++ b/docs/sccm/preparation/issue-329-distribution-point-corpus.md @@ -0,0 +1,176 @@ +# Issue #329 Distribution Point/content corpus preparation + +## Scope and dependency boundary + +This slice prepares the role-local source and behavior contract for Issue +`#329`. It intentionally contains only synthetic CCM evidence, versioned +manifest/expected-output labels, and a focused Rust fixture-contract test. +It does not add a production reducer, native collector, parser family, public +wire type, database dependency, or cross-side correlator. + +The preparation contract is `proposedPendingReviewed318And335`: + +- the reviewed #318 artifact, logical-record, evidence, timestamp, key, + coverage, redaction, signal, and finding contracts are the implementation + boundary; +- #335 supplies the producer-role, workflow-subject, configured-path, + physical identity, rotation, and coverage handoff; +- #322 remains independently callable and does not feed this role-local + preparation corpus; and +- #333 may later consume exact #322/#329 counterpart facts, but this slice + performs no correlation and makes no client-impact or causal claim. + +All `.log` files remain raw CCM transport. The corpus calls the existing +`normalize_ccm_artifact` logical-record path and does not introduce +`ParserKind::Sccm` or a second CCM parser. + +## Producer and workflow-subject contract + +A physical producer is not inferred from the workflow it describes. + +| Source ID | Basename | Allowed producer role | Workflow subject | Use | +| --- | --- | --- | --- | --- | +| `server-dp-distribution` | `distmgr.log` | `siteServer` | DP role scope; exact handle on each record | Receive and distribute | +| `server-dp-distribution` | `PkgXferMgr.log` | `siteServer` | DP role scope; exact handle on each record | Transfer and retry | +| `server-dp-distribution` | `SMSDPProv.log` | `distributionPoint` | same exact DP handle | Validate and make available | +| `server-dp-distribution` | `PullDP.log` | `distributionPoint` | same exact pull-DP handle | Pull transfer when a reviewed fixture proves it | +| `server-dp-serve` | `SMSdpmon.log` | `distributionPoint` | same exact DP handle | Optional, explicitly catalogued serving/status evidence | +| `client-content-control` | `DataTransferService.log` | `client` | selected DP only as an ignored control | Must never enter the server reducer | + +`server-dp-serve` is supplemental and bounded. It is not permission to scan +an IIS tree, content library, filesystem root, or arbitrary DP directory. +The existing IIS W3C parser may later support an explicitly catalogued +artifact, but this corpus neither requires nor fabricates one. + +Each manifest preserves: + +- a synthetic site code and one or more approved opaque DP handles; +- producer role and producer handle separately from workflow-subject role and + either an exact handle or the bounded `manifestTopology` basis used by one + site-server file that contains records for multiple declared DPs; +- source ID, exact basename, source grammar, synthetic version, path + fingerprint, and `SYNTHETIC://` provenance; path identities are compared + with Windows case-folding, and sanitized paths must use a declared + synthetic root plus the rotation-correct basename; +- rotation kind, nonempty typed lineage, and typed fragment completeness for + physical captures; +- capture state, collection timestamp, encoding, byte policy, exact copied + byte count, and bounded relative evidence path; and +- deterministic artifact identity and ordering. + +The two DPs in `content-version-mismatch` remain separate transaction subjects +even though one physical `distmgr.log` and one physical `PkgXferMgr.log` +contain records for both. A physical site-server source is captured once; +changing its path fingerprint or destination cannot duplicate it merely to +attach another workflow-subject handle. Each admitted logical record must +carry an exact DP handle from the bounded manifest topology. +Every manifest carries an explicit DP handle array parsed element by element; +missing, malformed, unknown, duplicate, or primary-omitting entries cannot be +projected away. + +## State and exact-key contract + +The proposed role-local state chain is: + +```text +ReceiveContent -> Distribute -> Transfer -> Validate -> MakeAvailable -> ServeOrReport +``` + +A transaction is admitted only when every cited logical record repeats the +same exact profile-valid tuple: + +```text +packageId ++ contentId ++ contentVersion ++ siteCode ++ distributionPointHandle ++ extractionProfileId +``` + +The synthetic profile is `dp-server-5.00.test-v1`, pinned to the exact +`5.00.TEST.0001` fixture version. Missing, malformed, unknown, or +prefix-collision versions cannot retain the exact profile. This is not a claim +that a real ConfigMgr build has been validated. + +The focused contract parses semicolon-delimited synthetic fields as unique +`Name=Value` pairs. Substring lookalikes, duplicate fields, missing fields, +case aliases, a changed version, or a changed DP handle cannot satisfy an +exact transaction. Observation order uses the additive normalized SCCM +timestamp provenance, not the legacy public `LogEntry.timezone_offset`. +Evidence later than the canonical bundle capture is rejected. Observation IDs +are nonempty and unique across transaction and source-local output classes, +and one physical `(artifactId, startLine, endLine)` reference can be consumed +only once across the scenario. + +The outcome rules are conservative: + +- success requires a cited terminal successful `ServeOrReport`; +- confirmed failure requires cited source-specific terminal failure evidence; +- retry remains `blockedOrDeferred`; +- incomplete coverage remains `insufficientEvidence` with exact physical gap + IDs and a bounded source ID; +- rotation fragments and malformed evidence remain noncorrelatable + source-local observations whose classification is bound to exact physical + role, capture state, lineage, rotation kind, and fragment completeness; + every source-local artifact ID is a nonempty typed string bound to that + physical manifest, and every source-local observation cites a nonempty + closed array of exact physical artifact/line ranges. A raw physical citation + does not make a fragment or malformed record transaction- or + correlation-eligible; and +- a client-only download record cannot become a DP transaction or DP failure. + +## Coverage and request contract + +`captured`, `absent`, `accessDenied`, `capped`, `skipped`, `unsupported`, and +`parseFailed` remain distinct physical manifest states. The expected coverage +array is an exact, sorted projection of physical artifact IDs and states. +Coverage-gap artifact IDs are nonempty typed strings, sorted, unique, and +bound to a declared non-complete physical artifact. + +Artifact requests contain only a catalogued source ID and one versioned reason +code: + +- `coverageAbsent` +- `coverageAccessDenied` +- `coverageCapped` +- `coverageMalformed` +- `coverageRotationSplit` + +There is no free-form collection request in the preparation labels. A reason +code must have matching noncomplete physical coverage. An absent default path +is a source gap; it cannot change an observed DP role to absent, broken, +uninstalled, unavailable, healthy, or failed. + +## Scenario matrix + +| Scenario | Required behavior | +| --- | --- | +| `healthy-package` | Exact six-phase successful distribution | +| `distribution-failure` | Terminal distribution failure with Receive as the last success | +| `transfer-retry` | Retry/backlog remains deferred, not failed | +| `validation-failure` | Terminal provider validation failure after exact transfer evidence | +| `content-version-mismatch` | Same package/content stays separate across versions and two DPs | +| `serve-observed` | Optional bounded serving source supplies the terminal observed outcome | +| `client-only-looking-request` | Same-time client content failure remains ignored server-side evidence | +| `rotation-boundary` | Current/`.lo_` fragments and malformed provider bytes form no transaction | +| `absent-dp` | Missing source candidates do not erase or diagnose an observed DP role | +| `incomplete` | Exact early phases survive while absent/denied downstream coverage requests the bounded source | + +The contract test also mutates exact versions, typed role topology, terminal +evidence, coverage states, role provenance, causal fields, canonical rotation +shapes, transaction cardinality, path fingerprints, safe segmented source and +destination paths, observation IDs, and evidence consumption. Each mutation +must fail closed. + +## Deferred implementation and validation + +After the #318 API gate and required restack/review, the production reducer may +map these labels onto the reviewed public contracts. It must remain pure Rust +and wasm32-compatible. Native capture remains a separate Windows adapter and +must retain configured paths, producer/subject topology, rotation, byte caps, +access results, and collision-safe identities. + +No committed fixture contains customer data, real hostnames, raw filesystem +paths, credentials, or live SCCM evidence. No live Windows or SCCM Server +acceptance is claimed by this preparation slice. diff --git a/docs/sccm/preparation/issue-330-software-update-point-corpus.md b/docs/sccm/preparation/issue-330-software-update-point-corpus.md new file mode 100644 index 000000000..76a386c24 --- /dev/null +++ b/docs/sccm/preparation/issue-330-software-update-point-corpus.md @@ -0,0 +1,172 @@ +# Issue #330 Software Update Point and WSUS corpus preparation + +## Scope and dependency boundary + +This slice prepares the server-local source, fixture, key, coverage, and +behavior contract for issue `#330`. It contains only synthetic CCM evidence, +versioned manifest/expected-output labels, and a focused Rust fixture-contract +test. It does not add a production reducer, native collector, parser family, +public wire type, database or network dependency, or cross-side correlator. + +The preparation contract is `proposedPendingReviewed318And335`: + +- reviewed #318 artifact, logical-record, timestamp, evidence, key, coverage, + signal, redaction, and finding contracts remain the implementation boundary; +- #335 supplies role/topology, configured-path, physical identity, rotation, + collection-limit, and coverage handoff; +- #323 remains independently callable and does not feed this server-local + preparation corpus; and +- #333 owns any later client update/SUP correlation. This slice performs none + and emits no client-impact or causal claim. + +All `.log` files remain raw CCM transport. The focused contract consumes the +existing `normalize_ccm_artifact` path and does not add `ParserKind::Sccm` or a +second CCM parser. The parser crate remains pure Rust and wasm32-compatible. + +## Bounded source and role contract + +Producer identity stays separate from the Software Update Point workflow +subject. + +| Source ID | Basename | Producer role | Workflow subject | Use | +| --- | --- | --- | --- | --- | +| `server-sup-sync` | `WCM.log` | `siteServer` | exact SUP handle | SUP configuration | +| `server-sup-sync` | `wsyncmgr.log` | `siteServer` | exact SUP handle | synchronization, metadata, and publish facts | +| `server-sup-sync` | `SUPSetup.log` | `softwareUpdatePoint` | same exact SUP handle | setup/configuration facts | +| `server-sup-sync` | `WSUSCtrl.log` | `softwareUpdatePoint` | same exact SUP handle | WSUS validation and terminal health facts | +| `server-sup-wsus` | `WsusHealth.json` | `wsUs` | exact SUP handle | optional profile-defined supplemental health | +| `client-updates-control` | `WUAHandler.log` | `client` | exact SUP handle only as ignored control | must not enter the server reducer | + +`server-sup-wsus` is optional and bounded. It is not permission to inspect an +arbitrary WSUS database, IIS tree, update catalog, filesystem root, registry, +WMI surface, or network endpoint. The `WsusHealth.json` label is a synthetic +profile-defined contract, not a supported native collector. + +Every manifest preserves the site code, opaque SUP and WSUS handles, observed +roles, producer role and host handle, workflow subject, exact source and +basename, grammar, synthetic source version, sanitized path/fingerprint, +rotation lineage, collection timestamp, and capture state. Physical artifact +records additionally preserve encoding, fragment completeness, byte cap, exact +copied-byte count, and a collision-safe evidence destination. Nonphysical +states omit encoding, byte, limit, relative-path, and fragment completion +facts. + +Rotation provenance is structural rather than self-asserted. A canonical +rotation kind/value must agree with the sanitized source basename and with the +collision-safe destination segment (`current`, `lo_`, `numbered-N`, or +`timestamped-YYYYMMDD-HHMMSS`). Numbered values are nonzero, timestamps are +canonical calendar values, lineage IDs are bounded safe tokens, and physical +source identity does not become unique merely because an artifact declares a +different rotation. + +An absent or access-denied default candidate is source coverage only. It cannot +erase an observed SUP role or prove the role healthy, failed, broken, +uninstalled, or unavailable. + +## State, key, and terminal-evidence contract + +The proposed role-local state chain is: + +```text +Configure -> Synchronize -> ImportOrProcessMetadata -> ValidateWsus + -> PublishAvailability -> HealthyOrTerminal +``` + +A proposed transaction admits only a profile-valid exact tuple: + +```text +syncRunId ++ siteCode ++ softwareUpdatePointHandle ++ optional exact updateId and KB pair ++ extractionProfileId +``` + +The synthetic profile is `sup-server-5.00.test-v1`, bounded to the exact +`5.00.TEST.0001` fixture version. Unknown or mixed source versions cannot +retain this selected profile or an exact/high result. This makes no claim +about a real ConfigMgr build. +Structured fields are unique, closed `Name=Value` pairs. Duplicate fields, +nested CCM-like text, aliases, unknown fields, partial update/KB pairs, or a +key not repeated by every cited record fail closed. + +The reducer contract is conservative: + +- success requires a cited terminal `HealthyOrTerminal` success; +- confirmed failure requires cited source-specific terminal failure evidence; +- `retrying` remains `blockedOrDeferred`, never inferred failure; +- incomplete manifest coverage remains `insufficientEvidence`, retains exact + gap IDs, and requests only a bounded source ID/reason code; +- skipped optional WSUS coverage lowers the confidence ceiling without + converting a cited terminal success to failure; +- rotation fragments and malformed bytes remain low-confidence, + noncorrelatable source-local observations; and +- a same-time client record, shared KB, or client-only update ID cannot enter a + server transaction or establish causality. + +Observation order uses normalized timestamp provenance plus artifact/bundle +capture chronology. Time alone is not a join key. + +## Coverage and bounded requests + +`captured`, `absent`, `accessDenied`, `capped`, `skipped`, `unsupported`, and +`parseFailed` remain distinct manifest states. Expected coverage is the exact +sorted projection of all manifest artifact IDs and states, including +nonphysical coverage outcomes. + +Requests use only a catalogued source ID and one of: + +- `coverageAbsent` +- `coverageAccessDenied` +- `coverageCapped` +- `coverageMalformed` +- `coverageRotationSplit` + +A reason code must be backed by matching noncomplete manifest coverage. There +is no free-form collection request in the preparation labels. + +## Scenario matrix + +| Scenario | Required behavior | +| --- | --- | +| `sync-success` | Six distinct phases end in cited terminal success | +| `wcm-configuration-failure` | WCM terminal failure has no invented prior success | +| `wsus-health-failure` | WSUS validation terminal failure retains metadata as the last success | +| `sync-retry` | Retry is deferred with configuration as the last success | +| `metadata-failure` | Metadata terminal failure remains distinct from WSUS health | +| `sup-setup-failure` | SUP setup terminal configuration failure stays role-local | +| `supplemental-wsus-skipped` | Optional skipped WSUS coverage lowers confidence only | +| `unrelated-update-key` | Same-time client failure with another update ID stays ignored | +| `rotation-boundary` | Split rotations plus malformed WSUS bytes form no transaction | +| `incomplete` | Early configuration survives while denied/absent downstream sources remain gaps | + +Permanent adversarial tests mutate exact keys, terminality, producer handles, +capture chronology, physical/nonphysical provenance, source-local +classifications, observation order, transaction cardinality, destination +collisions, unknown causal fields, and client update identity borrowing. Every +mutation must fail closed. + +Scenario `evidence/` trees are recursively closed against their physical +manifest artifacts, and every such artifact must appear in expected coverage. +Mutation-only byte sequences are stored outside all scenario trees under the +explicit versioned `software_update_point_mutation_assets/manifest.json` +test-only contract with exact byte counts and purposes. That contract is +schema-closed to `contractVersion`, `syntheticFixture`, `testOnly`, and +`assets`; each asset row is schema-closed to `assetId`, `relativePath`, +`bytesCopied`, and `testPurpose`. Captured-artifact or collection-manifest +vocabulary is rejected so mutation bytes cannot masquerade as collected +evidence. + +## Deferred implementation and validation + +Production `software_update_point.rs` implementation waits for the #318 API +gate and mandatory restack/review. It may then map the preparation labels onto +the reviewed public contracts without weakening this corpus. Native Windows +capture is a separate adapter concern and must retain configured paths, +producer/subject topology, rotation, access results, byte caps, and +collision-safe identities. + +No committed fixture contains customer data, real hostnames, raw filesystem +paths, credentials, tokens, live SCCM logs, or actual update metadata. No live +Windows, ConfigMgr, SUP, or WSUS acceptance is claimed by this preparation +slice. diff --git a/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md b/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md new file mode 100644 index 000000000..e46115fd1 --- /dev/null +++ b/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md @@ -0,0 +1,136 @@ +# Issue #331 hierarchy and replication corpus + +Status: preparation-only contract. Production extraction and reduction remain +blocked on the reviewed #318 finding boundary and the #335 native server intake +contract. This slice adds no native collection, database access, network access, +new parser family, or live Windows acceptance claim. + +## Evidence boundary + +Raw CCM remains the transport grammar. The corpus admits only the reviewed +site-server hierarchy family already declared by the shared catalog: + +| Source | Direction | Candidate phases | Required evidence | +| --- | --- | --- | --- | +| `replmgr.log` | origin | initiate, queue or serialize | exact message, link, origin site, target site, profile | +| `sender.log` | origin | send, retry, terminal send failure | exact message, link, origin site, target site, profile | +| `despool.log` | target | receive, process, terminal receive/process outcome | exact message, link, origin site, target site, profile | +| `rcmctrl.log` | target | acknowledge, healthy or terminal | exact message, link, origin site, target site, profile | + +The source name, a site-looking token, a remote host, or timestamp proximity +alone cannot create a transaction. Every transaction identity is derived from a +profile-validated message ID, link ID, origin site, and target site: + +```text +hierarchy:{messageId}:{originSiteCode}:{targetSiteCode}:{linkId} +``` + +Unknown profiles and partial keys are source-local candidates only. They must +retain a key-extraction gap and cannot be upgraded by another source merely +because its record occurred nearby in time. + +The synthetic profile `hierarchy-server-5.00.test-v1` admits only the exact +synthetic source version `5.00.TEST.0001`, the `siteServer` role, and an RFC3339 +`collectedUtc` value with a usable numeric or `Z` offset. A missing, malformed, +or different source version/time value is outside that profile. Its record may +remain source-local with an extraction gap, but it cannot retain an exact key, +exact topology, or high-confidence transaction output. + +## Topology and time + +Origin and target direction, safe host handle, site code, source path, +rotation lineage, and physical capture identity remain attached to every +artifact. Origin artifacts must use the declared origin host. Target artifacts +must use the host declared for the exact primary or additional target site in +their profile-recognized record. Cross-host ordering is usable only when each +cited record has usable offset provenance. Missing, conflicting, or invalid +offsets prevent a high-confidence ordered diagnosis even if terminal-looking +evidence exists. + +Two same-minute sender failures for different target sites are separate +transactions. The topology-mismatch fixture deliberately uses the same +message ID with different link and target-site keys; it produces no joined +transaction. The rotation fixture splits one transport record across current +and `.lo_` artifacts; neither fragment may emit a logical CCM record or a +terminal result. Candidate groups serialize in exact-key and full-provenance +order, so reversed artifact input is byte-identical while same-key facts with +different path, host, or rotation identity remain distinct. The immutable +transaction key never absorbs an artifact path, host handle, rotation, or line +range. Conversely, sharing that key never permits distinct evidence facts to be +deduplicated: every fact retains its full artifact and line provenance, and an +incompatible host, site, profile, or rotation remains source-local. + +## Coverage and conclusions + +The additive SCCM manifest keeps `captured`, `absent`, `accessDenied`, `capped`, +`skipped`, `unsupported`, and `parseFailed` distinct. A missing remote artifact +is a coverage state, not evidence that the remote role is absent or broken. + +The gap column below applies per artifact. A non-captured artifact enters a +transaction's `coverageGapArtifactIds` only when an exact transaction candidate +already exists; otherwise it remains a source-local coverage gap. + +| Coverage state | Gap mapping | Bounded request in this profile | +| --- | --- | --- | +| `captured` | No coverage gap; evidence still must parse and cite successfully | None | +| `absent` | One gap for that exact artifact ID when the source is required | `coverageAbsent` for that artifact's exact source/direction/site/host/basename basis | +| `accessDenied` | One gap for that exact artifact ID; never proof that the role failed | None until an access-remediation request reason is versioned | +| `capped` | One gap for that exact artifact ID when required evidence may be truncated | `coverageCapped` for that artifact's exact provenance basis | +| `skipped` | One gap for that exact artifact ID when the source was required | None; preserve the collection decision | +| `unsupported` | One gap for that exact artifact ID | None; do not imply that recollection can make the source supported | +| `parseFailed` | One gap for that exact physical artifact ID | None; retain the parse failure without converting it to absence | + +Coverage rows aggregate by artifact identity, never only by source name or +state: each manifest artifact has exactly one coverage row, and every +transaction gap ID resolves to exactly one non-captured manifest/coverage pair. +Multiple missing artifacts therefore remain sorted, unique per-artifact gaps; +they are not collapsed into a single broad “remote coverage” gap. Missing, +duplicated, empty, malformed, or unknown identities fail closed. + +`coverageRotationSplit` is not an eighth coverage state. It is permitted only +for the exact current/`.lo_` sender pair with one lineage, canonical +basename/rotation identities, one direction, and one site/host mapping. +`invalidOffset` is likewise a time-provenance request reason, not a coverage +state. Every request must equal the provenance derived from its evidence; +`both` is valid only when both origin and target evidence are actually present. +No request is synthesized for `accessDenied`, `skipped`, `unsupported`, or +`parseFailed` under this profile. Bounded follow-up requests name only the +relevant hierarchy source, exact direction, target site, mapped host, and +basenames. + +The proposed state sequence is: + +```text +Initiate -> QueueOrSerialize -> Send -> Receive -> Process + -> Acknowledge -> HealthyOrTerminal +``` + +Retry/backlog without a terminal record remains `blockedOrDeferred`. A +high-confidence success or confirmed failure requires cited terminal evidence, +an exact validated key, compatible topology, usable time provenance, and no +coverage gap. A later success is recovery only for the same exact immutable +key. Contradictions remain visible. + +No client impact, remote root cause, site-wide impact, or cross-side causal +claim is produced here. Future correlation remains owned by #333 and must use a +separately reviewed pair; time alone is never eligible. + +## Scenario matrix + +| Scenario | Contract | +| --- | --- | +| `healthy-link` | Complete exact-key path ends in cited acknowledgement/terminal success | +| `sender-failure` | Same-minute failures to CHD and SEC remain two terminal transactions | +| `receiver-processing-failure` | Cited send precedes a terminal target processing failure | +| `backlog-retry` | Nonterminal retry remains medium-confidence deferred evidence | +| `recovery` | Later same-key send/process success produces recovery | +| `absent-remote-source` | Missing target source is a low-confidence gap with one bounded request | +| `clock-offset-unknown` | Invalid offsets prohibit high-confidence cross-host ordering | +| `generic-site-token` | A valid generic CCM record with `CHD` but no exact hierarchy grammar creates no candidate | +| `topology-mismatch` | Same message with incompatible link/target keys remains unlinked | +| `rotation-boundary` | Partial current/`.lo_` fragments never form a record or transaction | +| `incomplete` | Capped partial origin evidence remains source-local coverage | + +All committed bytes are synthetic and sanitized. The in-progress SCCM Server +lab may later validate native discovery and source semantics, but it is not an +acceptance source for this preparation slice. diff --git a/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md b/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md new file mode 100644 index 000000000..c15593271 --- /dev/null +++ b/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md @@ -0,0 +1,123 @@ +# Issue #332 Provider/Admin Service preparation + +## Status + +This slice prepares the source and reducer contracts only. The reviewed #318 +and #335 prerequisites are now available, but this correction does not expand +the preparation PR into production extraction/reduction. No Windows +collection, network call, SQL/WMI query, database access, Tauri command, or +live SCCM acceptance is included. + +## Source contract + +The existing pure Rust catalog already distinguishes: + +- `Smsprov.log` as Provider-family CCM from the `provider` producer role; +- `AdminService.log` as Admin-Service-family CCM from the `provider` producer + role. + +Issue `#332` keeps producer role and workflow layer separate. The proposed +`server-provider` and `server-admin-service` source IDs preserve the exact +endpoint handle and sanitized configured-path provenance. An optional +`server-admin-service-iis` source is scoped W3C context only. An unknown or +arbitrary IIS tree stays unsupported/supplemental and cannot be promoted into +an Admin Service transaction. + +## Request and privacy contract + +A request transaction is derived only from this exact tuple: + +~~~text +layer + normalized request ID + safe operation handle + endpoint ID + + compatible role/topology + selected versioned extraction profile +~~~ + +Caller identity, authorization/token material, query text, URL parameters, +certificate details, and endpoint path are excluded from keys and public +summaries. The privacy scenario uses reserved synthetic values to prove the +private raw fixture contains sensitive-shaped input while expected public +output does not. + +The Provider and Admin Service fixtures intentionally reuse the same request +ID in the privacy scenario. They remain separate because the layer, +operation, and endpoint components differ. A timestamp or endpoint alone can +never construct the exact transaction ID. + +## State contracts + +Provider: + +~~~text +Receive -> AuthenticateOrAuthorize -> ExecuteProviderOperation + -> Respond -> RecordOutcome +~~~ + +Admin Service: + +~~~text +Receive -> AuthenticateOrAuthorize -> Route -> ExecuteBackendOperation + -> Respond -> RecordOutcome +~~~ + +Not every source must emit every intermediate phase. Phase movement remains +monotonic. A confirmed failure requires explicit source-specific terminal +evidence; a timeout, missing response, unknown version, invalid offset, or +split rotation remains incomplete or source-local. + +## Coverage and provenance + +Each artifact pins: + +- SCCM-specific capture state; +- producer role and opaque host handle; +- workflow layer and endpoint; +- source ID, original basename, and sanitized source path; +- collision-resistant path fingerprint; +- rotation kind, lineage, and fragment completeness; +- source version, collection time, encoding, cap, byte count, and safe + relative path for physical evidence. + +Nonphysical states may not invent physical file provenance. A complete +transaction citation must refer to captured, complete, normalized CCM +evidence from the same layer. Supplemental IIS evidence is source-local and +noncorrelatable. + +## Test-first record + +The first focused test failed because the initial eleven-scenario fixture root +did not exist. After the corpus was added, the privacy transaction test failed +because the public redactor correctly replaced a sensitive tail with a +redaction marker; the fixture-field reader was narrowed to recognize that +marker without weakening exact key checks. + +A later mutation pass reproduced seven fail-open cases before correction: +control-bearing versions, blank rewritten artifact identity, arbitrary +transaction/source-local observation IDs, high confidence over an incomplete +fragment, arbitrary outcomes, and omitted required bounded requests. The +closed contract now rejects all seven. + +A second pair of independent mutation rounds added nineteen permanent +regressions. The latest six bind admitted exact-key records to citations, +terminality to `recordOutcome`, collection-limit application to coverage, +relative paths to source/endpoint/rotation identity, unknown versions to a +closed public grammar, and exact topology to a nonempty endpoint. The corpus +also now contains an explicit same-key retry/recovery scenario and a +contradictory-terminal scenario that remains incomplete and low-confidence. + +The current twenty-scenario matrix additionally separates absent, +access-denied, capped, skipped, unsupported, parse-failed/malformed, and +blocked/deferred coverage. None of those states is promoted into success or +failure. Every high-success transaction now cites the complete layer-specific +phase sequence, and every retryable failure requires a later success at the +same phase. + +## Explicit limits + +- This is not a production reducer. +- This is not a native capture adapter. +- This does not prove any Provider/Admin Service role exists from a default + path. +- This does not support broad IIS parsing. +- This does not claim client, console, API consumer, or cross-side causality. +- The in-progress SCCM Server lab is a future validation source, not current + acceptance evidence. diff --git a/docs/sccm/preparation/issue-333-correlation-contracts.md b/docs/sccm/preparation/issue-333-correlation-contracts.md new file mode 100644 index 000000000..e86dccdc3 --- /dev/null +++ b/docs/sccm/preparation/issue-333-correlation-contracts.md @@ -0,0 +1,49 @@ +# Issue #333 correlation-design preparation + +## Status + +This slice prepares adversarial fixtures and executable fixture-schema tests only. It intentionally adds no `src/sccm/correlation` module, shared SCCM model change, public correlation API, pair reducer, graph store, native collection, live query, or cross-side finding. + +Production work remains blocked while: + +- #318's exact shared finding/redaction/key interface is still under review; +- #321 and #328 have synthetic source corpora but no accepted public pair-fact interface; +- #322 has a synthetic deployment/content corpus but no accepted public pair-fact interface; +- #329's DP corpus is not independently accepted on the program baseline. + +## Prepared pair contracts + +| Pair | State | Implementation permission | Known boundary | +| --- | --- | --- | --- | +| #321 policy to #328 Management Point | `contractPrepared` | Disabled | Requires exact profile-validated policy/request keys, compatible site/MP topology, usable ordering for sequence claims, coverage, and corroborating terminal evidence | +| #322 content to #329 Distribution Point | `contractPrepared` | Disabled | Requires exact content/package identity plus required version, compatible DP topology, usable ordering for sequence claims, coverage, and corroborating terminal evidence | +| #323 updates to #330 SUP | `candidate` | Disabled | Requires a dedicated reviewed subplan after both source contracts are independently accepted | + +The two first-pair matrices are independent. Policy behavior never depends on content output, and content behavior never depends on policy output. + +## False-causality matrix + +Each first pair instantiates all thirteen guards: + +| Guard | Required conservative result | +| --- | --- | +| Missing client/server counterpart | Preserve source-local output and request only the named counterpart artifact group | +| Same-time/no-key | Candidate symptom at most; time is not a causal key | +| Conflicting exact key | Incompatible/unlinked; do not attach the terminal fact | +| Incompatible topology | Incompatible with a bounded reason; do not blame either side | +| Unknown profile | Candidate at most; unvalidated extraction cannot create an exact link | +| Version mismatch | Incompatible, including same content ID with a different required version | +| Invalid offset | No cross-host ordering claim; ExactPartial at most | +| Partial capture | Explicit coverage gap and bounded request | +| Rotation split | Explicit coverage gap; fragments cannot synthesize a logical cross-side fact | +| Unrelated terminal error | Preserve it as source-local evidence only | +| Redaction boundary | Public projection uses safe handles and excludes private markers | +| Reordered input | Identical expected result and serialization | + +All adversarial scenarios set `highConfidenceCauseAllowed=false`, `exactCorroboratedAllowed=false`, and `sourceFindingsMutable=false`. A future healthy/terminal implementation matrix must be added test-first only after the corresponding upstream public fact contracts pass independent review. + +## Evidence classification + +The matrix uses synthetic repository fixtures where they are already merged, explicit `issue:#329:` references for pending DP scenarios, and pair-local `synthetic:` placeholders for inputs that do not yet exist. These references are design evidence, not native or live acceptance. + +Client-only, server-only, missing, partial, capped, invalid-offset, rotation-split, and unknown-profile cases remain usable coverage outputs. None is proof of a server or client cause. The development SCCM Server is a future sanitized validation source and has not been exercised by this slice. diff --git a/docs/sccm/preparation/issue-335-server-intake.md b/docs/sccm/preparation/issue-335-server-intake.md new file mode 100644 index 000000000..66ca37c08 --- /dev/null +++ b/docs/sccm/preparation/issue-335-server-intake.md @@ -0,0 +1,243 @@ +# Issue #335 preparation: role-aware server intake + +## Scope and dependency boundary + +This is an implementation-ready preparation package for #335, not a parser or +collector implementation. It freezes synthetic fixture intent while #318 owns +the common serialized SCCM types, coverage vocabulary, evidence identifiers, +redaction handles, and logical-record API. No fixture asserts a Rust type or +function name before those contracts are public. + +The intake contract is role-aware: a source is classified only from declared +role/topology provenance plus a catalogued basename and rotation. A missing +default candidate is `Absent` for that candidate only. It never proves that a +role is missing, broken, uninstalled, or healthy. + +## Source catalog (pre-#318 declaration) + +Producer role is the topology that emitted/stored the artifact. Workflow +subject is the role or instance whose work the artifact describes. They are +separate facts: a site-server `distmgr.log` record can describe distribution +to a DP, and a site-server `wsyncmgr.log` record can describe SUP sync, without +turning either file into a DP- or SUP-produced artifact. + +| Source ID | Candidate basename(s) | Allowed producer role | Workflow subject | Grammar | Collection rule / consumer | +| --- | --- | --- | --- | --- | --- | +| `server-sitecomp` | `sitecomp.log`, `hman.log` | `siteServer` | site core | CCM | current + rotations; #327 | +| `server-status` | `statmgr.log`, `statesys.log` | `siteServer` | site status | CCM | current + rotations; #327 | +| `server-mp-auth` | `MP_GetAuth.log` | observed MP/site-system producer | management point | CCM | current + rotations; retain observed placement; #328 | +| `server-mp-auth` | `MP_CliReg.log`, `MP_RegistrationManager.log` | observed MP/site-system producer | management point | CCM | current + rotations; native placement must be retained; #328 | +| `server-mp-policy` | `MP_GetPolicy.log`, `MP_Location.log` | observed MP/site-system producer | management point | CCM | current + rotations; retain observed placement; #328 | +| `server-mp-policy` | `mpcontrol.log` | `siteServer` | management point | CCM | current + rotations; #328 | +| `server-mp-iis` | explicitly captured W3C export | observed IIS site-system producer | management point | IIS W3C | optional, scoped; #328 | +| `server-dp-distribution` | `distmgr.log`, `PkgXferMgr.log` | `siteServer` | distribution point/content | CCM | current + rotations; #329 | +| `server-dp-distribution` | `SMSDPProv.log` | observed DP producer | distribution point/content | CCM | current + rotations; #329 | +| `server-dp-distribution` | `PullDP.log` | observed pull-DP producer | distribution point/content | CCM | current + rotations; #329 | +| `server-dp-serve` | explicitly catalogued serving/status export | observed DP producer | distribution point/content | profile-defined | optional supplemental; #329 | +| `server-sup-sync` | `WCM.log`, `wsyncmgr.log` | `siteServer` | software update point | CCM | current + rotations; #330 | +| `server-sup-sync` | `SUPSetup.log`, `WSUSCtrl.log` | observed site-system producer | software update point | CCM | current + rotations; #330 | +| `server-sup-wsus` | explicitly scoped WSUS health/sync export | observed WSUS producer | software update point | profile-defined | optional, bounded; #330 | +| `server-iis-status` | curated IIS/status export | observed IIS site-system producer | discovered role only | IIS W3C | optional; skipped by default | + +Microsoft's ConfigMgr documentation places `distmgr.log` and +`PkgXferMgr.log` on the site server while identifying `smsdpprov.log` on the +DP ([content-library troubleshooting](https://learn.microsoft.com/en-us/intune/configmgr/core/plan-design/hierarchy/the-content-library) and +[Package Transfer Manager](https://learn.microsoft.com/en-us/intune/configmgr/core/plan-design/hierarchy/package-transfer-manager)). +The official [log-file reference](https://learn.microsoft.com/en-us/intune/configmgr/core/plan-design/hierarchy/log-files) +likewise places `WCM.log` and `wsyncmgr.log` on the site server, and +`SUPSetup.log`/`WSUSCtrl.log` on a site-system server. Those references justify +the producer/subject split; they do not prove a lab's configured path, host +identity, co-located roles, or version-specific placement. Native discovery +must retain the observed producer topology and record unresolved placement +rather than broadening an allowed producer set. + +The same reference separates MP/site-system-produced `MP_GetAuth.log`, +`MP_GetPolicy.log`, and `MP_Location.log` candidates from the +site-server-produced `mpcontrol.log`. They are deliberately separate catalog +rows. A co-located or otherwise ambiguous deployment retains its observed +producer handle plus unresolved placement provenance until native Windows +validation; basename or workflow ownership never resolves that ambiguity. + +An overlapping basename or workflow subject is never enough to infer a server +source or producer role. Artifacts with undeclared source IDs, basenames, +rotations, or producer combinations remain `Unsupported`/unclassified evidence +and cannot enter a role reducer. + +## Manifest and provenance handoff + +The synthetic manifests use a stable, intentionally provisional JSON shape. +`sccmManifestVersion: 1` is the proposed server manifest version; actual +serde field names and tolerant-reader behavior are deferred to #318. + +- `syntheticFixture: true` and `proposalOnly: true` make the committed safety + and pre-#318 schema boundary machine-readable. +- `topology.rolesObserved` is a list of observed facts, never path guesses. +- Each artifact retains `producerRole`, a privacy-safe producer host handle, + optional `workflowSubject`, `sourceId`, `configuredPathProvenance`, + `originalBasename`, `rotation`, `captureState`, nullable `relativePath`, + byte/count provenance, and collection time. Within one manifest/bundle, + `artifactId` is unique across every artifact, including non-captured states. + Reusing the same deterministic ID in an independent bundle is valid; there + is no corpus-global namespace. +- `artifactId` is derived from the canonical producer role/host, source, + workflow-subject role/instance, path fingerprint, basename, and rotation + identity. Discovery position, task completion order, and a mutable counter + are never inputs. A duplicate canonical identity or ID inside one manifest + is rejected before any evidence write. +- Every `Captured`/`Capped` artifact carries explicit `encoding` and + `collectionLimit` (`byteLimit`, `limitApplied`) provenance. A completed + capture models its policy even when the limit was not reached. Non-captured + artifacts omit these fields unless a future contract explicitly represents + them as unavailable/null. +- Capture limits apply inclusively to raw file bytes before decoding. A capped + artifact is the exact prefix of the source through byte `byteLimit`; the + collector neither decodes first nor splits, repairs, or replaces bytes to + form text. It records `bytesCopied == file size == byteLimit`, + `truncated: true`, and `fragmentComplete: false`. +- `originalPath` is always a privacy marker in committed fixtures. The opaque + `pathFingerprint` distinguishes configured roots without publishing them. +- `rotation.lineageId` joins current and rotated members of a source only; it + is not a cross-role identifier. `relativePath` includes producer/source and, + when needed, deterministic workflow-subject instance and configured-root + discriminators so colliding basenames cannot overwrite or merge. The + preparation key segment is the first 16 lowercase hexadecimal characters of + SHA-256 over the UTF-8 NFC approved opaque handle; duplicate destinations + are rejected during preflight rather than disambiguated by discovery order. +- Evidence retains `artifactId`, a full logical `lineRange`, and a synthetic + text payload. Evidence payloads are raw and bundle-internal. Any public + evidence projection and any derived field must pass the #318 redaction + boundary and may retain only approved opaque handles/statuses. The future + normalizer must frame before extraction. + +## Native capture adapter design (deferred implementation) + +1. A Windows-only discovery boundary returns observed role facts and configured + candidate roots with discovery method and failure detail. A default path may + be added as a candidate but may not create `rolesObserved`. +2. The engine selects only catalogued roles/sources, canonicalizes each path, + rejects reparse/symlink escapes outside the allow-listed configured root, + and enforces per-source file and inclusive raw-byte caps. The byte count and + prefix copy occur before text decoding; the collector never repairs a + truncated encoding boundary. +3. Before opening any destination, capture canonicalizes every artifact + identity, workflow-subject/root collision key, and final bundle-relative + path for the full batch. Duplicate identity/path preflight fails the batch. + Each accepted destination is created atomically with create-new/no-overwrite + semantics; a concurrent or pre-existing path is an explicit capture error, + never a replacement. +4. Collision-safe paths include deterministic opaque subject-instance and + configured-root segments when either can vary, for example + `evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/instance-17eae15500d8968f/root-b11afca548220198/current/wsyncmgr.log`. + The raw path or instance value must not be recoverable from those segments. + Two roots or instances remain distinct IDs/evidence references and cannot + overwrite or normalize-merge. Partial copies remain `Capped`; they do not + become success. +5. The writer projects server fields into a versioned manifest without changing + generic `ArtifactStatus`. Access, cap, skipped, unsupported, absent, and + parse failure stay distinct. Raw evidence stays internal to the bundle. A + public export runs both evidence and derived values through #318 redaction, + removes raw host/path/content values, and retains only + source/producer/workflow-subject/rotation, approved opaque handles, and + allowed statuses. +6. Native acceptance needs Windows CI temp-path tests plus an authorized SCCM + lab. The lab is not a prerequisite for parser corpus work and is currently + pending; this package makes no live-capture claim. + +Deferred native tests must make the write/privacy boundaries observable: + +- a fake batch with colliding roots/instances must fail during preflight with + zero destination files created; a pre-existing destination must remain + byte-identical after atomic create-new fails; +- a capped source whose next byte crosses a decoding boundary must retain the + exact raw prefix and size without replacement/repair before the parser sees + it; and +- a protected bundle may contain sentinel raw host/path/evidence/derived + values, but its serialized public projection must contain none of those + sentinels while preserving only the expected approved opaque + handles/statuses. + +## Intake assessment rules + +- Normalized schema-v1 coverage rows group by producer role, optional opaque + producer-host handle, source ID, optional workflow-subject role and opaque + instance handle, and capture state. Every `artifactId` in a row is therefore + bound to the exact topology retained by its normalized artifact; a role, + source, and state match alone cannot merge physical producers or workflow + subjects. +- `producerHostHandle` and `workflowSubjectHandle` are additive optional fields + on normalized schema-v1 coverage JSON and are omitted when absent. They retain + only intake-validated opaque handles, never raw host names or paths. This + additive change does not silently advance `schemaVersion`: Rust consumers + constructing `SccmServerCoverage` with struct literals must supply the new + fields, and strict JSON readers that reject unknown fields must add them to + their accepted schema before reading rows where they are present. +- Classify by `(producer role/topology, source ID/basename, supported rotation, + provenance)`, not filename, workflow subject, or default path alone. +- Stable-normalize artifacts by producer role/host handle, source ID, + workflow-subject role/instance/basis, path fingerprint, explicit rotation + family rank, within-family value, lineage ID, basename, capture state, + relative path, then artifact ID. Equality through the final ID is a rejected + duplicate identity, so no input-order tie remains. +- Rotation family rank is timestamped, numbered, `.lo_`, current, + provider-defined, then none. Timestamped values sort ascending by valid + `YYYYMMDD-HHMMSS`; numbered values sort by descending integer; remaining + ties use lineage ID, basename, capture state, relative path, and artifact ID + in binary-stable lexical order. Canonical spellings are replacement-extension + `.lo_`, numbered `.log.N`, and `.log.YYYYMMDD-HHMMSS`. This is serialization + order only: intended lineage/record chronology is evaluated separately and + is never inferred from array position. +- For every admitted complete record, its authoritative UTC instant is derived + only from a syntactically valid date/time/offset and must be less than or + equal to `collectedUtc` with zero synthetic tolerance. A timestamped + rotation's filename/value instant must be less than or equal to the earliest + admitted record instant in that member. Invalid/unknown offsets are + non-comparable coverage gaps: they receive no invented UTC, reordering, or + correlation. +- Missing required evidence yields a role-scoped coverage gap and a minimal + next artifact request. It does not yield a role-health finding. +- `AccessDenied`, `Capped`, `Skipped`, `Unsupported`, and `ParseFailed` are + preserved exactly. A partial, malformed, or unframed rotation boundary can + create only coverage/parse gaps or a low-confidence symptom. +- Legacy generic records are eligible only through an explicit adapter that + supplies role/source provenance. A generic `Failed` status must not be + rewritten as `AccessDenied`, `Capped`, or `ParseFailed`. + +## Fixture test matrix + +| Scenario | Primary proof | Expected conservative result | +| --- | --- | --- | +| `complete-multi-role` | Site/MP plus site-produced DP/SUP control evidence with explicit subjects | producer topology and workflow subject stay separate; no health conclusion | +| `configured-nondefault-path` | observed MP configured root has opaque non-default provenance | source retained; absent default remains candidate-only | +| `collision-same-basename-configured-roots` | two current `MP_GetPolicy.log` files from distinct configured roots | distinct fingerprints/opaque path segments/IDs/references; neither overwrite nor merge | +| `rotations` | current, `.lo_`, numbered, timestamped files share lineage | unique collision-safe artifacts in stable rotation order | +| `multiline` | two physical lines form one complete CCM record | one logical evidence record with full `1-2` range | +| `absent-dp` | DP candidate absent and no observed DP role | DP coverage gap; never `DP broken` | +| `access-denied-mp` | MP policy candidate cannot be read | access coverage plus bounded reread request; no terminal MP result | +| `capped-sup` | site-server `wsyncmgr.log` for an SUP workflow is retained only to a byte cap | capped coverage and no terminal SUP health conclusion | +| `skipped-iis` | optional IIS supplemental source intentionally skipped | skip preserved; no required-source failure | +| `unsupported-db-supplement` | unknown DB export has explicit unsupported metadata | retained outside reducers; no inferred database/role state | +| `unsorted-manifest` | source order differs from canonical order | canonical source order and byte-identical normalized output | +| native fake-discovery (future) | role facts/configured roots are explicit | no default-path role inference; documented discovery failure state | +| native collision/cap/unsafe-path (future) | same basename, caps, and reparse escape | no overwrite; `Capped`; unsafe candidate rejected/skipped with provenance | +| legacy adapter (future) | generic artifact lacks SCCM fields | incomplete server bundle only when explicit provenance is supplied | + +## #318 field-mapping blockers + +The following are deliberately unresolved and must be mapped against #318 +before implementation or compiling tests are added: + +| Preparation field | Needed #318 contract | Decision required | +| --- | --- | --- | +| `captureState` | serialized coverage/capture enum | exact wire strings and unknown form | +| `producerRole` / `producerHostHandle` / `workflowSubject` | producer/subject topology model | canonical roles, safe host/instance handles, and non-inference rules | +| `configuredPathProvenance` | privacy/redaction and provenance model | safe handle/fingerprint representation | +| `encoding` / `collectionLimit` | capture provenance model | encoding enum/string and limit-policy wire shape | +| `rotation` | rotation and source identity model | rotation ordering and accepted syntax | +| `topology` / `rolesObserved` | role/topology model | canonical role enum and additive fields | +| `evidence` / `lineRange` | evidence reference and logical-record model | evidence ID derivation and line range schema | +| `expected` coverage entries | coverage gap/finding/result model | stable snapshot schema and next-artifact request form | +| legacy mapping | generic artifact adapter | explicit incomplete/unknown status behavior | + +Until #318 lands, the JSON files are design fixtures only. They must not be +compiled as tests or used to imply native collection, server discovery, role +health, client impact, or client/server causality. diff --git a/docs/sccm/source-catalog/advanced-roles.md b/docs/sccm/source-catalog/advanced-roles.md new file mode 100644 index 000000000..3e0b730be --- /dev/null +++ b/docs/sccm/source-catalog/advanced-roles.md @@ -0,0 +1,63 @@ +# SCCM advanced-role source-card catalog + +Issue: #334 + +Card schema: `1.0.0` + +Evidence status: synthetic contract only + +This catalog is a gate for future source discovery. It does not add a parser, reducer, transaction, finding, native collector, or live Windows acceptance claim. The candidate basenames are capture-discovery hints, not assertions that a role is configured or that a file exists. Missing, denied, capped, skipped, unsupported, malformed, and partial sources remain coverage states. + +## Admission contract + +Every card records a stable card ID/version, role and family, candidate basenames and configured path classes, raw parser family, source-version scope, bounded capture and rotation policy, privacy classes, healthy and terminal evidence requirements, correlation policy, fixture IDs, issue ownership, semantic limits, next evidence, and supersession state. + +Promotion is monotonic and evidence-bound: + +| State | Required evidence | Permitted use | +| --- | --- | --- | +| `candidate` | Reviewable source mapping only | Bounded capture guidance | +| `observed` | Sanitized role, configured-path, and source-version provenance | Bounded capture guidance | +| `fixtureValidated` | Observed provenance plus success, failure, coverage, privacy, and rotation fixtures | Contract testing; no production reducer | +| `ruleValidated` | Exact key, phase, terminal, version, privacy, and incomplete-bundle tests plus a linked implementation issue | Eligible for a production semantic catalog | +| `deferred` | A precise unsupported reason and next evidence | Coverage preservation only | + +Only a valid `ruleValidated` card with a linked implementation issue and named reducer can enter a semantic analyzer. Candidate, observed, fixture-validated, and deferred cards must set `captureGuidanceOnly`, cannot create transactions, and cannot create failure findings. Time-only correlation is forbidden at every state. Unknown parser or promotion values are preserved as inspectable strings and rejected deterministically rather than panicking or being silently admitted. + +Public projection is restricted to nonsensitive card, role, capture, and coverage metadata. A card with privacy classes or medium/high sensitivity must require redaction, and raw sensitive field projection is always rejected. + +## Initial cards + +No initial card has sanitized lab-observed provenance, so none has a follow-up implementation issue or semantic admission. + +| Card ID | Candidate scope | Raw grammar | State | Privacy | Exact next promotion evidence | +| --- | --- | --- | --- | --- | --- | +| `certificate-enrollment-pki` | Certificate registration point; candidate `crp.log` | CCM | `candidate` | High: certificate, device, and subject identity | Authorized role/path/version observation; success, terminal, privacy, incomplete, and rotation fixtures | +| `client-notification-bgb` | Server-side notification and management-point context; candidate `BgbServer.log` | CCM | `candidate` | High: device, user, and notification payload | Sanitized server-role observation and independently validated server-versus-client notification keys | +| `cloud-service-connection` | Service connection point and CMG connection point; candidates `CloudMgr.log`, `SMS_Cloud_ProxyConnector.log` | CCM | `candidate` | High: tenant, endpoint, certificate, and token-like data | Authorized configured-role observation followed by privacy review and bounded scenario fixtures | +| `osd-pxe` | PXE-enabled distribution point and site server; candidate `smspxe.log` | CCM | `candidate` | High: device, MAC, network, and resource identity | Sanitized configured-role observation plus topology, privacy, rejection, rotation, malformed, and incomplete fixtures | +| `reporting` | Reporting services point; candidate `srsrp.log` | CCM | `candidate` | High: report, query, account, and data-source identity | Sanitized configured-role observation and bounded redaction fixtures | +| `sql-database-export` | Explicit operator-provided database supplement | Unsupported | `deferred` | High: database, query, device, and user identity | Separately approved data-minimized export contract, authorization model, schema, and fixtures | + +Candidate names must be confirmed against configured role provenance before promotion. A missing default path never proves that a role is absent or broken. Database access is not a parser fallback and is not authorized by this card. + +## Determinism and lifecycle + +Catalog filenames and card IDs are sorted and unique. Nested role, basename, path, privacy, version-prefix, fixture, key, and supersession lists are also sorted where ordering affects serialization or comparison. Active cards cannot name a successor. Deprecated cards must name a valid successor present in the catalog and can never be semantically admitted. Every `supersedes` entry must be a well-formed card ID naming a card present in the catalog. Supersession metadata cannot promote a card. + +The synthetic catalog-fixture matrix (`missing-required-field`, `redaction-required`, `unvalidated-source`, `valid`) proves: + +- a valid candidate remains outside the semantic catalog; +- a missing required owner is rejected; +- a candidate cannot declare a production reducer or diagnostic capabilities; +- high-sensitivity data cannot disable redaction or project raw sensitive fields. + +Dedicated admission tests prove: + +- unknown parser and promotion values are retained for review and rejected (`unknown_parser_and_promotion_values_are_preserved_then_rejected`); +- RuleValidated admission still requires validated, nonempty, deterministic key kinds (`only_a_fully_linked_rule_validated_card_is_semantically_admitted`); +- deprecation without an existing catalog successor is rejected, and deprecated cards remain outside semantic admission (`deprecation_requires_an_explicit_successor_and_never_panics`). + +## Native validation boundary + +The development SCCM Server may later provide sanitized observed provenance, but it is not a blocker for this contract and has not been exercised by this slice. Any future promotion must update the individual card with evidence IDs and fixtures, open a dedicated implementation issue, obtain review, and rerun the parser, wasm32, strict Clippy, formatting, and manifest checks before semantic admission. diff --git a/docs/superpowers/plans/2026-07-30-sccm-client-extended.md b/docs/superpowers/plans/2026-07-30-sccm-client-extended.md new file mode 100644 index 000000000..217cbb0ba --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-sccm-client-extended.md @@ -0,0 +1,577 @@ +# SCCM Client Extended Workflow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver issues #324, #325, and #326 as independent, evidence-first SCCM Client analyzers for Task Sequences, inventory/compliance/metering, and co-management/scripts/notification/Software Center. + +**Architecture:** Build on the shared SCCM spine (#318) and deterministic Client intake contract (#319), but keep each extended workflow in a separate reducer with a precise source catalog, transaction key, state machine, fixture corpus, and failure boundary. These workflows may share evidence/coverage/key/finding types only; they must not use app deployment or policy reducer state as an undocumented substitute for their own evidence. + +**Tech Stack:** Rust 1.88, `cmtraceopen-parser`, `cmtrace-open` native SCCM bundle adapter, serde/serde_json, existing CCM logical-record parser, synthetic fixture corpus, Windows SCCM Client development host for source-path and live-log validation. + +## Global Constraints + +- #318 and #319 are required before implementation. #320–#323 can inform a UI/workspace later but are not required to make these analyzers correct. +- This plan implements #324, #325, and #326 only. It does not add server role capture, management-point/DP/SUP rules, client-to-server correlation, or an SCCM workspace UI. +- No `ParserKind::Sccm`, no per-log raw parser, no direct filesystem or Windows API use in `cmtraceopen-parser`. Every semantic record comes through #318's complete logical-record evidence path. +- A source's default path is a discovery candidate, never proof that a source must exist on every client, boot phase, client version, or co-management configuration. +- A missing/relocated Task Sequence log, absent inventory source, unavailable notification channel, or client-side workload ownership handoff is explicit coverage/capability state—not a failure by default. +- Each analyzer uses stable evidence references, exact validated keys, and source/version provenance. Same filename, approximate time, component name, or a generic error code alone never merges unrelated transactions. +- Preserve task execution context, client identity, user identity, command lines, token-like data, and internal host/path values behind #318's redaction boundary. Fixtures must have synthetic paths and opaque test identifiers. +- `SMSTSLogPath` and actual captured artifact provenance are authoritative for Task Sequence source location; do not guess a stage from a single default `smsts.log` path. +- Co-management workload ownership is a first-class terminal classification. If a workload is Intune-owned, SCCM is allowed to explain its own observed handoff but must not diagnose an Intune failure. +- Do not claim an SCCM client notification, Software Center, inventory, or Task Sequence cause until a terminal/corroborating record exists. A red record alone may create a symptom. +- Native Windows acceptance validates discovery/capture layout and permissions. Pure fixture tests must cover all diagnostic semantics even when no lab is available. + +--- + +## Issue Sequencing and Review Boundaries + +| Issue | Narrow outcome | Required prior contract | Review focus | Future dependency | +| --- | --- | --- | --- | --- | +| #324 | One Task Sequence execution reconstructed across capture locations/rotations | #318/#319 | execution identity, relocation, phase/terminal semantics | later OSD/server correlation only after a specific server pair is designed | +| #325 | Separate inventory, compliance, and metering transactions | #318/#319 | no conflation of collection/evaluation/reporting | future device health workspace views | +| #326 | Ownership-aware client management diagnostics | #318/#319 | workload handoff, optional source capability, no cross-platform overreach | future Intune and SCCM client workspaces | + +Do not combine all three issues into one implementation PR. #324 is higher-risk because it crosses boot environments and log relocation; it should be its own PR series. #325 may split its three reducers into reviewable commits beneath the issue if the shared source contract remains stable. #326 must begin with a source/capability catalog gate before it begins semantic finding rules. + +## File Structure and Ownership + +```text +crates/cmtraceopen-parser/ +├── src/sccm/client/ +│ ├── mod.rs # public re-exports + analyze_client_bundle composition +│ ├── task_sequence.rs # #324 TS source/instance state machine +│ ├── inventory.rs # #325 inventory, compliance, metering reducers +│ └── management.rs # #326 co-management/scripts/notification/Software Center reducers +├── src/sccm/catalog.rs # shared source names + capability metadata; no I/O +├── tests/ +│ ├── sccm_client_task_sequence.rs +│ ├── sccm_client_inventory.rs +│ ├── sccm_client_management.rs +│ └── fixtures/sccm/client/ +│ ├── task_sequence//{manifest.json,evidence/,expected.json} +│ ├── inventory//{manifest.json,evidence/,expected.json} +│ └── management//{manifest.json,evidence/,expected.json} + +src-tauri/ +├── src/sccm/intake.rs # extend only with catalogued discovery/capture candidates +├── src/sccm/manifest.rs # preserve source/capability/path/rotation provenance +└── tests/sccm_client_intake.rs # native temporary-path/regression cases only +``` + +The parser crate's extended analyzers must not read `src-tauri` types. Conversely, the native intake layer must not implement workflow state machines. No code in this plan changes generic `collector::ArtifactStatus` without an independently reviewed generic schema migration. + +## Shared Result Contract + +Every workflow returns a shared `SccmWorkflowAnalysis` composed of stable `SccmTransaction` and `SccmFinding` records. Each transaction must include at least: + +```rust +pub struct SccmTransaction { + pub transaction_id: String, + pub workflow: SccmWorkflow, + pub phase: SccmPhase, + pub state: SccmTransactionState, + pub last_successful_phase: Option, + pub keys: Vec, + pub evidence: Vec, + pub coverage_gap_artifact_ids: Vec, +} +``` + +For every analysis result, assert these reviewer-visible properties: + +- transaction/finding/evidence/key/request arrays are deterministically sorted; +- findings link to exact artifact/entry references or explicit coverage, never an unreferenced summary; +- `ConfirmedFailure` cannot be emitted without terminal/corroborating evidence defined by that workflow's catalog/version profile; +- `BlockedOrDeferred` distinguishes intentional wait/reboot/maintenance/handoff from a terminal failure; +- `InsufficientEvidence` names the smallest logical artifact group needed next; +- a malformed record/unknown version/invalid offset yields degraded confidence, never fabricated ordering; +- raw sensitive context is not present in exported/snapshot output. + +## Task 1: Establish #324 Task Sequence source and execution-identity contracts + +**Files:** + +- Create: `crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/catalog.rs` +- Create: `crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/README.md` +- Create: fixture directories `winpe`, `post-format`, `pre-client`, `client-installed`, `completed`, `relocated-fragments`, `unrelated-runs`, `rotation-boundary`, and `incomplete` +- Modify only after pure tests specify a new candidate: `src-tauri/src/sccm/intake.rs`, `src-tauri/src/sccm/manifest.rs`, and `src-tauri/tests/sccm_client_intake.rs` + +**Consumes:** #318 artifact/evidence/coverage/timestamp/key/finding contracts and #319's versioned manifest/capture adapter. + +**Produces:** A source catalog and execution-identity model that lets a Task Sequence analyzer know which captured `smsts` fragment belongs to a possible execution without treating a path or filename as the execution key. + +### Candidate source rule + +`smsts.log` is deliberately modeled as a dynamic captured artifact family. Candidate locations may include WinPE, temporary setup, post-format, full-OS, and client-installed locations, but the source catalog must never hard-code a single path as required. The native manifest records the observed original path and an optional sanitized path class (`winpe`, `setup`, `fullOs`, `client`, `unknown`) derived from an allow-listed discovery rule. The pure parser consumes only the artifact provenance/path class; it does not calculate a Windows path. + +### Execution key rule + +The preferred execution key is a profile-validated execution/run identifier or a stable combination of Task Sequence package/advertisement plus explicit run context. A mere `smsts.log` filename, machine name, timestamp range, task-sequence display name, or single step message is insufficient. When the identifier cannot be safely extracted, expose a low-confidence unlinked execution observation; do not combine it with any other fragment or later workflow. + +- [ ] **Step 1: Write failing source and identity tests before parsing TS semantics** + +Add tests that load synthetic manifests/fragments and assert: + + - `winpe`, `post-format`, `pre-client`, and `client-installed` artifacts retain their observed path classes and do not lose provenance after bundle normalization; + - fragments with the same `smsts.log` basename from `winpe` and `fullOs` remain separate until an exact execution key joins them; + - `relocated-fragments` with the same validated execution key joins in deterministic evidence order across a path transition; + - `unrelated-runs` with similar timestamps but different validated execution IDs never join; + - a rotation tail/malformed start cannot emit an execution key; + - absence of an `smsts` candidate produces a Task Sequence coverage gap, not “no Task Sequence ran.” + +Use an explicit API assertion: + +```rust +let result = analyze_client_task_sequence(&load_bundle("task_sequence/relocated-fragments")); +assert_eq!(result.transactions.len(), 1); +assert_eq!(result.transactions[0].keys[0].kind, SccmCorrelationKeyKind::TaskSequenceExecutionId); +assert_eq!(result.transactions[0].evidence.len(), 4); +``` + +- [ ] **Step 2: Run the focused test target and preserve the red failure** + +Run: + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_task_sequence source_and_execution_identity +``` + +Expected: FAIL because no Task Sequence module/catalog/API exists. + +- [ ] **Step 3: Implement candidate classification and key-safe fragment grouping** + +Add only catalog metadata and pure grouping code in this step. The catalog should recognize `smsts.log` plus explicitly declared rotation forms; it may not classify arbitrary `*.log` files as Task Sequence evidence. `task_sequence.rs` must turn captured artifacts into fragment groups, preserve original artifact/path class/rotation evidence refs, then use #318 extraction-profile results to form execution candidates. Treat missing source version or unknown key pattern as a coverage/key-extraction gap. + +For native intake, add a test-first, bounded candidate list. Capture only explicitly configured/observed allowed locations; preserve an unrecognized location as `unknown` rather than copying an entire disk. Test that each captured path is canonicalized inside an approved root and that duplicate `smsts.log` names receive distinct bundle-relative destinations. + +- [ ] **Step 4: Make the source contract green** + +Run: + +```bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_task_sequence source_and_execution_identity +cargo test --locked -p cmtraceopen-parser --test sccm_client_intake +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo test --locked -p cmtrace-open --test sccm_client_intake --features sccm-diagnostics +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +``` + +- [ ] **Step 5: Commit the #324 source/key contract before phase rules** + +```bash +git add crates/cmtraceopen-parser/src/sccm/client crates/cmtraceopen-parser/src/sccm/catalog.rs crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence src-tauri/src/sccm src-tauri/tests/sccm_client_intake.rs +git commit -m "feat(sccm): model task sequence source provenance" +``` + +If native TS discovery requires materially different permissions or collection behavior from #319, keep it in a follow-up commit under #324 and document the separation in the issue. + +## Task 2: Implement #324 Task Sequence state-machine analysis + +**Files:** + +- Modify: `crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Modify: `crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs` +- Modify: Task Sequence fixture directories with `expected.json` phase/finding contracts + +**Consumes:** Validated execution groups from Task 1 and shared evidence/finding builders. + +**Produces:** One transaction per safe Task Sequence execution, with relocation-aware phase progression and conservative terminal/deferred finding output. + +### State contract + +```text +Start -> Preflight -> DiskOrImage -> SetupWindows -> InstallClient -> InstallSoftware -> PostAction -> Complete +``` + +The exact names can evolve only through a versioned profile/change review. The reducer must distinguish a phase boundary observed in WinPE from a phase observed after path relocation. `Complete` needs terminal completion evidence for the same execution. A reboot, continuation handoff, or expected setup transition is `BlockedOrDeferred`/in progress—not a failed run. + +- [ ] **Step 1: Add phase-specific failing fixtures/tests** + +Add assertions for: + + - a completed run across relocation has one transaction and reaches `Complete`; + - a terminal preflight failure has no later phase and is `ConfirmedFailure` only with terminal evidence; + - a disk/image phase failure does not become an application deployment failure; + - setup transitions from WinPE to full OS are recorded as expected boundary/deferred evidence when the same execution key is proven; + - client installation failure stays in `InstallClient` and requests only relevant client setup evidence if coverage is incomplete; + - software installation failure after a completed client install stays in `InstallSoftware` and does not reuse #322's app transaction as a cause; + - reboot/continuation after an evidenced phase is deferred, not terminal; + - a complete-looking message without exact execution key is low confidence; + - fragments from two runs with matching times never combine; + - an incomplete final log requests the next `task-sequence` artifact/path class rather than declaring failure. + +- [ ] **Step 2: Run the entire #324 target red** + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_task_sequence +``` + +Expected: FAIL because only source grouping exists; no phase reducer/finding rules should have been implemented in Task 1. + +- [ ] **Step 3: Implement a per-execution monotonic reducer** + +Represent source-local facts with phase candidate, evidence ref, terminality, and execution key. Sort safely, process only one execution candidate at a time, and retain contradictions instead of moving backward silently. A later success may demonstrate recovery only under the same exact execution key and coherent timestamp/provenance. Emit a high-confidence terminal failure only under the shared #318 finding validation rules plus a TS profile-recognized terminal fact. + +For a partial path sequence, add a coverage gap describing the missing path class/captured continuation rather than assuming an error. Never request an unbounded Windows volume capture; requests must name a logical Task Sequence artifact and supported path class/reason. + +- [ ] **Step 4: Add deterministic/negative regressions and run full gates** + +Add tests for input-order invariant JSON, invalid offset ordering downgrade, unknown TS version profile, redacted execution context, and rotation physical-fragment isolation. + +Run: + +```bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_task_sequence +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +``` + +- [ ] **Step 5: Commit the #324 diagnostic slice and record validation limits** + +```bash +git add crates/cmtraceopen-parser/src/sccm/client crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence +git commit -m "feat(sccm): analyze task sequence execution evidence" +``` + +In #324, record which path classes and ConfigMgr/OS deployment profile versions have sanitized fixtures. Leave unobserved boot/recovery variants as explicit coverage gaps, not broad source support claims. + +## Task 3: Establish #325 independent inventory, compliance, and metering source/transaction contracts + +**Files:** + +- Create: `crates/cmtraceopen-parser/src/sccm/client/inventory.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/catalog.rs` +- Create: `crates/cmtraceopen-parser/tests/sccm_client_inventory.rs` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory/README.md` +- Create: fixture directories `inventory-success`, `inventory-provider-wmi`, `inventory-queue-failure`, `compliance-success`, `compliance-evaluation-failure`, `compliance-remediation`, `compliance-reporting-failure`, `metering-success`, `metering-collection-failure`, `mixed-unrelated`, and `incomplete` + +**Consumes:** #318 shared contracts and #319 bundle intake. Source names are admitted only after catalog/fixture evidence validates them. + +**Produces:** Three separate transaction families, never one generic “client reporting” conclusion. + +### Initial source catalog rule + +Start with explicit candidate groups and mark uncertain names as provisional until a sanitized fixture/source reference proves their grammar: + +| Logical group | Candidate log families | Consumed by | Required semantics | +| --- | --- | --- | --- | +| `client-inventory` | `InventoryAgent.log`, `InventoryProvider.log`, `InventoryAgentProvider.log` when observed | hardware/software inventory | collection, provider, serialization, queue/send, report | +| `client-compliance` | `CIAgent.log`, `CITaskMgr.log`, `DCMAgent.log`, `DCMReporting.log`, `StateMessage.log` when observed | configuration item/compliance | evaluate, remediate, report state | +| `client-metering` | `SWMTRReportGen.log` and explicitly observed metering logs | software metering | collect, aggregate, report | + +The actual catalog must use only observed/proven suffixes. A provisional entry is allowed to be captured/represented as `Unsupported` or `Candidate`, but must not create a diagnostic phase rule until a test fixture and reviewed profile validate it. + +### State contracts + +```text +Inventory: Collect -> Provider -> Serialize -> Queue -> Report +Compliance: Evaluate -> Remediate -> Report +Metering: Collect -> Aggregate -> Report +``` + +Compliance remediation is a separate phase from evaluation. A non-compliant state is not itself a client collection failure. Inventory queue trouble cannot become a compliance diagnosis, even if both appear in the same StateMessage artifact. + +- [ ] **Step 1: Write failing source separation tests** + +Require tests to prove that input evidence creates distinct `SccmWorkflow` values/transactions for inventory, compliance, and metering; a CI/resource/state ID is not assumed to identify a software-metering report; and source coverage is tracked per workflow. Include a fixture with same-minute inventory and compliance failures that cannot merge. + +- [ ] **Step 2: Run the #325 target red** + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_inventory +``` + +Expected: FAIL because module/catalog/reducers do not exist. + +- [ ] **Step 3: Implement catalog admission and three narrow fact extractors** + +Create private source-specific fact structures—`InventoryFact`, `ComplianceFact`, and `MeteringFact`—each preserving evidence refs, profile version, candidate phase, keys, and terminality. Catalog admission must be table-driven and testable. Never scan arbitrary messages for terms such as “inventory”/“compliance” to create a workflow. + +Use keys appropriate to each family: resource/inventory cycle IDs where profile-validated; CI/baseline/state IDs for compliance; metering/report identifiers for metering. If a key is unknown/unvalidated, retain a source-local symptom and coverage/key gap rather than attaching it to a transaction. + +- [ ] **Step 4: Make source and basic transaction tests green** + +Run: + +```bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_inventory source_ +cargo test --locked -p cmtraceopen-parser --test sccm_client_inventory separates_ +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +git diff --check +``` + +Expected: PASS. Do not add terminal diagnoses until Task 4 fixtures are red first. + +- [ ] **Step 5: Commit source/transaction foundations separately** + +```bash +git add crates/cmtraceopen-parser/src/sccm/client crates/cmtraceopen-parser/src/sccm/catalog.rs crates/cmtraceopen-parser/tests/sccm_client_inventory.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory +git commit -m "feat(sccm): define inventory compliance and metering evidence" +``` + +## Task 4: Implement #325 state reducers and findings + +**Files:** + +- Modify: `crates/cmtraceopen-parser/src/sccm/client/inventory.rs` +- Modify: `crates/cmtraceopen-parser/tests/sccm_client_inventory.rs` +- Modify: #325 fixture expected files + +**Consumes:** The source/fact contracts from Task 3. + +**Produces:** Evidence-backed state completion, failure, deferred, and coverage outputs for each of the three workflows. + +- [ ] **Step 1: Add failure/coverage twins for each workflow** + +For inventory, test provider/WMI failure, queue/send failure, a successful later report recovery with the same exact cycle key, and missing report coverage. For compliance, test successful evaluation, terminal evaluation error, remediation action/result, non-compliant-but-evaluated state, report failure, and missing StateMessage coverage. For metering, test collect/aggregate/report success, collection failure, unknown source version, and absence of metering source. + +Every failed fixture must assert class/confidence/last success/evidence/request. Every healthy fixture must assert no spurious `ConfirmedFailure`. Every missing-source twin must assert `InsufficientEvidence` with a specific group request. + +- [ ] **Step 2: Run the complete #325 target red** + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_inventory +``` + +Expected: FAIL because the Task 3 fact extractors should not yet make final state claims. + +- [ ] **Step 3: Implement three isolated finite reducers** + +Write one reducer per state contract. Permit a later recovery only with the same validated transaction key and safe source ordering. Treat explicit non-compliance as a compliance evaluation result, not a client malfunction. A queue/report failure must name the failed last step and request only the next relevant artifact if coverage is incomplete. Preserve contradictory evidence as low confidence rather than discard it. + +- [ ] **Step 4: Run detailed test/compatibility gates** + +```bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_inventory +cargo test --locked -p cmtraceopen-parser --test sccm_client_intake +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +``` + +- [ ] **Step 5: Commit #325's reducers and issue evidence** + +```bash +git add crates/cmtraceopen-parser/src/sccm/client/inventory.rs crates/cmtraceopen-parser/tests/sccm_client_inventory.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory +git commit -m "feat(sccm): analyze inventory compliance and metering" +``` + +Update #325 with the precise catalogued sources and validated profile/version scope. Do not call the entire inventory/compliance ecosystem supported from a small initial corpus. + +## Task 5: Establish #326 co-management, scripts, notification, and Software Center capability contracts + +**Files:** + +- Create: `crates/cmtraceopen-parser/src/sccm/client/management.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/catalog.rs` +- Create: `crates/cmtraceopen-parser/tests/sccm_client_management.rs` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/README.md` +- Create fixture directories `co-management-intune-owned`, `co-management-sccm-owned`, `co-management-unknown`, `script-success`, `script-failure`, `script-incomplete`, `notification-received`, `notification-deferred`, `software-center-observed`, `software-center-insufficient`, and `mixed-unrelated` + +**Consumes:** #318 contracts and #319 catalog/intake. Native intake is extended only for explicit, tested source candidates. + +**Produces:** Capability/ownership classification before any management diagnostic state machine runs. + +### Candidate source rule and ownership boundary + +Start with source names verified in source documentation or sanitized lab fixtures, such as `CoManagementHandler.log`, `Scripts.log`, `CcmNotificationAgent.log`, and explicitly observed Software Center client logs. BGB/server logs are server evidence and must not enter client source catalog just because notification traffic relates to them. If a Software Center log name/version is not yet validated, represent it as a candidate/unsupported artifact and open a narrow source-contract follow-up rather than guessing parsing behavior. + +Co-management classification must make one of these outcomes before a workload analyzer runs: + +```text +SccmOwned | IntuneOwned | SharedOrTransitioning | UnknownOwnership +``` + +`IntuneOwned` means SCCM evidence observed/indicates handoff; resulting finding is a handoff/capability observation, not an Intune root-cause diagnosis. `UnknownOwnership` blocks high-confidence workload conclusions and requests the minimal co-management evidence. + +- [ ] **Step 1: Write capability/ownership tests first** + +Assert that an Intune-owned workload causes a terminal handoff classification with cited `CoManagementHandler` evidence, no SCCM failure claim, and no request for every SCCM source. Assert SCCM-owned and transitioning cases remain distinct. Assert missing co-management evidence is unknown rather than a default SCCM-owned assumption. Assert unsupported Software Center candidates remain capability gaps, not parsed generic logs. + +- [ ] **Step 2: Run #326 management target red** + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_management capability_and_ownership +``` + +Expected: FAIL because management catalog/model/reducer APIs do not exist. + +- [ ] **Step 3: Implement admission/capability model before operational reducers** + +Create a private source admission table and an ownership resolver that consumes only version-profile-validated co-management records. It returns a precise classification with evidence refs, confidence, and coverage gaps. Do not use registry/tenant state directly in the parser crate. Native capture may include an explicitly structured registry export only if #319's manifest and privacy review permit it; otherwise leave the source missing and lower confidence. + +- [ ] **Step 4: Verify the capability contract** + +```bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_management capability_and_ownership +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +``` + +- [ ] **Step 5: Commit the ownership gate alone** + +```bash +git add crates/cmtraceopen-parser/src/sccm/client/management.rs crates/cmtraceopen-parser/src/sccm/catalog.rs crates/cmtraceopen-parser/tests/sccm_client_management.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/management +git commit -m "feat(sccm): classify client management ownership" +``` + +## Task 6: Implement #326 scripts, notification, and Software Center analysis behind the ownership gate + +**Files:** + +- Modify: `crates/cmtraceopen-parser/src/sccm/client/management.rs` +- Modify: `crates/cmtraceopen-parser/tests/sccm_client_management.rs` +- Modify: management fixture expected files + +**Consumes:** Task 5 ownership/capability results, shared evidence/signals/keys/findings, and admitted source catalog entries. + +**Produces:** Independent script, notification, and Software Center analyses that are explicitly scoped to SCCM-client evidence. + +### State contracts + +```text +Script: Receive -> Execute -> Report +Notification: Receive -> DeferOrDispatch -> Acknowledge +SoftwareCenter: ObserveRequest -> ClientAction -> ObserveOutcome +``` + +The Software Center contract is intentionally observational. It may report that a request/action/outcome was or was not evidenced in catalogued client records, but does not assert UI rendering, user intent, or server-side availability without dedicated evidence. Notification `Deferred` is not a delivery failure unless a terminal acknowledgement/timeout record exists for the same validated notification key. + +- [ ] **Step 1: Add failing operational fixture tests** + +Require: + + - script success/reported state; + - terminal script failure with exact script/execution key and preserved exit signal; + - missing final script report as insufficient evidence rather than success/failure; + - received notification and deferred notification separate from terminal notification failure; + - a generic service error in the same minute does not attach to a notification; + - Software Center observed action/outcome only when supported catalog evidence exists; + - unavailable/unsupported Software Center log declares capability insufficiency; + - Intune-owned work does not emit SCCM script/deployment causality even if an unrelated SCCM log contains an error. + +- [ ] **Step 2: Run #326 target red** + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_management +``` + +Expected: FAIL because Task 5 implements only capability/ownership classification. + +- [ ] **Step 3: Implement scoped reducers** + +Extract source-local facts per admitted group, group by exact validated script/notification/action keys, and run the small state machine. Require terminal source-specific facts for high-confidence failure. Existing #318 signal extraction enriches raw codes but does not by itself assign a script/notification phase. Attach ownership classification to every management result and cap confidence at low/medium whenever ownership is transitioning/unknown. + +- [ ] **Step 4: Add redaction/order/coverage regressions and run checks** + +Ensure exported output masks user context/command arguments, artifact input reordering has stable JSON, unknown source versions cannot create exact command/action keys, and partial logical records cannot establish a terminal result. + +Run: + +```bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_management +cargo test --locked -p cmtraceopen-parser --test sccm_client_inventory +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +``` + +- [ ] **Step 5: Commit #326 operational analysis separately from the ownership gate** + +```bash +git add crates/cmtraceopen-parser/src/sccm/client/management.rs crates/cmtraceopen-parser/tests/sccm_client_management.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/management +git commit -m "feat(sccm): analyze client management evidence" +``` + +## Task 7: Run the extended-client acceptance and issue-review gate + +**Files:** + +- Create: `docs/sccm/validation/client-extended-lab-checklist.md` +- Modify: GitHub issues #324–#326 with fixture/test/validation evidence +- Modify parser README only if actual public API calls need user-facing documentation + +**Consumes:** Completed issue slices, pure test corpus, and an authorized development client/lab when available. + +**Produces:** Reviewable completion evidence that distinguishes fixture coverage from live Windows source acceptance. + +- [ ] **Step 1: Run every focused and aggregate parser test** + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo test --locked -p cmtraceopen-parser --test sccm_client_intake +cargo test --locked -p cmtraceopen-parser --test sccm_client_task_sequence +cargo test --locked -p cmtraceopen-parser --test sccm_client_inventory +cargo test --locked -p cmtraceopen-parser --test sccm_client_management +cargo test --locked -p cmtraceopen-parser +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo fmt --check --all +git diff --check +``` + +- [ ] **Step 2: Run native candidate-regression tests** + +```bash +cargo test --locked -p cmtrace-open --test sccm_client_intake --features sccm-diagnostics +cargo test --locked -p cmtrace-open --test esp_diagnostics_sources --all-features +cargo test --locked -p cmtrace-open --test parser_expanded_corpus --all-features +cargo clippy --locked -p cmtrace-open --all-targets --all-features -- -D warnings +``` + +- [ ] **Step 3: Validate source candidates on a Windows development client only** + +The lab checklist must ask for ConfigMgr/OS version, boot context, observed TS path class, selected non-production synthetic scenario, discovered candidate source names, access/cap behavior, capture limits, sanitization/replacement map, and explicit statement of which candidates were not observed. Do not use live boot/task sequence evidence or real client identifiers as committed fixtures. + +- [ ] **Step 4: Inspect one JSON output per branch before review** + +For each issue inspect: a success, a terminal failure, a deferred/handoff/non-failure state, an incomplete coverage case, and an adversarial same-time/unrelated case. Check source provenance, exact evidence refs, state/last-success distinction, confidence cap, minimal artifact requests, stable serialized ordering, and exported redaction. + +- [ ] **Step 5: Update issue closure status conservatively** + +For #324 list validated path classes, execution-key/profile scope, and untested boot/relocation variants. For #325 list each admitted source family and separate state machines. For #326 list ownership classifications/source candidates and clarify that Intune-owned work is not diagnosed by this issue. Keep an issue open if any required corpus case or Windows capture acceptance is absent; a successful compile is not closure evidence. + +## Exit Criteria + +### #324 Task Sequence + +- [ ] Dynamic/relocated `smsts` capture provenance is preserved and no filename-only merging occurs. +- [ ] Execution transaction keys are exact/profile-validated; unkeyed fragments remain low-confidence observations. +- [ ] Every defined phase has success, terminal, boundary/deferred, contradictory, rotation, and missing-coverage fixtures. +- [ ] Native path candidate validation is recorded separately from pure parser acceptance. + +### #325 Inventory, compliance, metering + +- [ ] Three workflow source catalogs/fact extractors/reducers remain separate. +- [ ] Non-compliant is not conflated with client/collection failure; queue/report failure is not conflated with evaluation. +- [ ] Unknown source/version/key produces explicit coverage/key gaps rather than a false transaction. +- [ ] Each workflow has healthy, terminal, recovery/contradictory, and incomplete fixture contracts. + +### #326 Client management + +- [ ] Co-management ownership is resolved or explicitly unknown before action-level diagnoses. +- [ ] Intune-owned/shared/transitioning workloads do not produce SCCM root-cause claims. +- [ ] Scripts, notification, and Software Center analyses only consume catalogued source evidence and distinguish deferred/capability gaps from failure. +- [ ] Raw command/user/context values remain redacted in public output and fixtures. diff --git a/docs/superpowers/plans/2026-07-30-sccm-client-intake-and-core.md b/docs/superpowers/plans/2026-07-30-sccm-client-intake-and-core.md new file mode 100644 index 000000000..b772ab38c --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-sccm-client-intake-and-core.md @@ -0,0 +1,808 @@ +# SCCM Client Intake and Core Workflow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver issues #319, #320, #321, #322, and #323 as a deterministic SCCM Client intake bundle plus evidence-backed health/location, policy, application/content, and software-update diagnoses. + +**Architecture:** The pure parser crate owns client source classification, normalized evidence consumption, workflow transactions, and findings. The native crate owns bounded Windows source discovery and capture into an SCCM-specific manifest. CCM remains the one raw record grammar; no client workflow reparses physical log lines or introduces a `ParserKind::Sccm`. Every analyzer returns its last proven phase, cited evidence, coverage gaps, and the smallest useful next artifact request instead of a causal guess. + +**Tech Stack:** Rust 1.88, Cargo workspace, `cmtraceopen-parser`, `cmtrace-open` Tauri backend, serde/serde_json, regex, chrono, existing CCM parser, existing native ESP discovery only as an implementation pattern, Windows SCCM Client development host for final collection validation. + +## Global Constraints + +- #318 is a hard dependency. This plan must consume its public `SccmArtifact`, `SccmEvidence`, coverage, signal, key, timestamp, redaction, and finding contracts rather than defining client-private replacements. +- This plan owns #319 through #323 only. Do not add Task Sequence, inventory, compliance, co-management, scripts, notification, Software Center, server-role rules, cross-side correlation, or workspace UI here. +- `cmtraceopen-parser` remains pure and `wasm32-unknown-unknown` compatible. It cannot read paths, glob, copy files, inspect a registry, invoke WMI, query a service, or communicate over the network. +- Keep raw CCM parsing in `crates/cmtraceopen-parser/src/parser/ccm.rs`. Workflow extraction starts from complete logical records supplied through the SCCM spine; `parse_lines` is never a semantic evidence input. +- Do not add `ParserKind::Sccm`, a parser kind per client log, or a second CCM regular expression. Source names map to SCCM workflow catalog entries above the shared `ParserKind::Ccm` transport grammar. +- Preserve current generic collection-bundle behavior. `ArtifactStatus` currently represents only `Collected`, `Missing`, and `Failed`; do not silently overload it to mean access denied, capped, skipped, unsupported, or partial SCCM coverage. +- The SCCM Client native bundle gets an additive, versioned SCCM manifest/extension. Its reader must tolerate a generic legacy manifest and map only unambiguous legacy states; no existing generic bundle consumer may break. +- An absent source means only absent coverage. It must create an `InsufficientEvidence`/coverage result, never an assertion that the client is healthy, targeted, not targeted, or failing. +- Unknown client version, unknown message pattern, malformed logical record, or split rotation must lower confidence and retain raw-safe evidence rather than extrapolating a workflow state. +- Use only synthetic fixture identities: `LAB-CLIENT-01`, the three-character site code `LAB`, RFC-style UUIDs, fake package/content IDs, and no customer paths, users, SIDs, tokens, certificates, tenant IDs, serials, or real deployment names. +- Windows SCCM Client collection behavior is accepted only on Windows CI and the development client. macOS validates deterministic pure parser and native test-double behavior, not Windows filesystem/ACL semantics. + +--- + +## Scope, Dependencies, and Ship Order + +| Issue | Deliverable | Starts after | May run in parallel with | Blocks | +| --- | --- | --- | --- | --- | +| #319 | Curated client source catalog, versioned bundle manifest, deterministic current/rotation intake | #318 | #335 server intake | #320–#326 | +| #320 | Setup/service/identity/location transaction | #319 source contract | #321–#323 analyzer implementation | Reliable prerequisite findings | +| #321 | Policy request-to-report transaction | #319 and #320 vocabulary | #322/#323 | First policy-to-MP correlation in #333 | +| #322 | App/package/content deployment transaction | #319 | #320/#321/#323 | First content-to-DP correlation in #333 | +| #323 | Software-update transaction | #319 | #320–#322 | Future SUP correlation after #330 | + +Land #319 before invoking any analyzer against a live client. After #319, parser-only analyzer PRs may proceed independently provided they use the frozen shared fixture schema and public #318 contracts. Do not make #322 wait for #321 implementation: it may receive an absent policy artifact as explicit coverage and request it. Do not start #333 implementation from this plan; it only emits stable keys/evidence needed by #333. + +## File Structure and Ownership + +The exact directories are deliberately split by pure semantics versus native I/O: + +```text +crates/cmtraceopen-parser/ +├── src/sccm/ +│ ├── mod.rs # #318 public façade; add client re-export only +│ ├── models.rs # #318 shared models; do not add workflow-local wire types +│ ├── catalog.rs # #318 filename/role primitives; extend catalog ownership here +│ └── client/ +│ ├── mod.rs # public client bundle/analyzer façade +│ ├── intake.rs # expected client source groups + coverage projection +│ ├── health.rs # #320 setup/service/identity/location state machine +│ ├── policy.rs # #321 policy transaction state machine +│ ├── deployment.rs # #322 app/package/content transaction state machine +│ └── updates.rs # #323 software-update transaction state machine +├── tests/ +│ ├── sccm_client_intake.rs # pure catalog/coverage/ordering contract +│ ├── sccm_client_health.rs # #320 behavior contract +│ ├── sccm_client_policy.rs # #321 behavior contract +│ ├── sccm_client_deployment.rs # #322 behavior contract +│ ├── sccm_client_updates.rs # #323 behavior contract +│ └── fixtures/sccm/client/ +│ ├── README.md # schema, sanitization, replay instructions +│ ├── intake// # manifest + current/rotation source evidence +│ ├── health// # setup/location cases +│ ├── policy// # policy state-machine cases +│ ├── deployment// # app/content cases +│ └── updates// # update cases + +src-tauri/ +├── Cargo.toml # add an opt-in sccm-diagnostics feature and test target +├── src/lib.rs # compile-gated native SCCM module declaration +├── src/sccm/ +│ ├── mod.rs # native-only surface, not a UI/workspace feature +│ ├── intake.rs # bounded client discovery/candidate evaluation +│ ├── bundle.rs # SCCM bundle layout + manifest reader/writer adapter +│ └── manifest.rs # SCCM manifest schema v1 serialization and legacy mapping +└── tests/ + └── sccm_client_intake.rs # temp-directory native discovery/capture/manifest tests +``` + +Do not put native capture code under `crates/cmtraceopen-parser/src/sccm`. Do not put client analyzers under `src-tauri/src/esp`, reuse ESP code as a narrow private implementation reference only after tests show it does not carry ESP state/session assumptions. + +## Shared Client Contracts Consumed from #318 + +The spine owns serialized types. This plan adds only client workflow enums and behavior that use those types. The public client façade should be small and deterministic: + +~~~rust +// crates/cmtraceopen-parser/src/sccm/client/mod.rs +pub fn analyze_client_bundle( + bundle: &SccmNormalizedBundle, +) -> SccmBundleAnalysis; + +pub fn assess_client_intake( + artifacts: &[SccmArtifact], +) -> SccmClientIntakeAssessment; +~~~ + +Each per-workflow analyzer remains independently callable for tests and future dedicated Client workspace views: + +~~~rust +pub fn analyze_client_health( + bundle: &SccmNormalizedBundle, +) -> SccmWorkflowAnalysis; + +pub fn analyze_client_policy( + bundle: &SccmNormalizedBundle, +) -> SccmWorkflowAnalysis; + +pub fn analyze_client_deployment( + bundle: &SccmNormalizedBundle, +) -> SccmWorkflowAnalysis; + +pub fn analyze_client_updates( + bundle: &SccmNormalizedBundle, +) -> SccmWorkflowAnalysis; +~~~ + +`SccmWorkflowAnalysis` must contain the workflow name, stable sorted transactions, stable sorted findings, workflow-scoped coverage gaps, and artifact requests. It must not contain a private copy of evidence, a filesystem path, a raw execution context, or a mutable global cache. + +Client workflow transaction models should use domain phases, but findings use the shared `SccmPhase`/`SccmFinding` contract. The enum values below are intentionally explicit so reviewers can reject a skipped phase rather than infer behavior from a message name: + +| Workflow | Transaction phases | Minimum stable keys | +| --- | --- | --- | +| Health/location | Setup, Service, Identity, SiteAssignment, ManagementPoint, Transport | client GUID, site code, management-point host | +| Policy | Request, Download, Persist, Schedule, Evaluate, Report | policy/assignment ID, client GUID, site code, policy request ID | +| Deployment | Intent, Requirements, LocateContent, Transfer, Cache, Enforce, Detect, Report | assignment ID, CI ID, package/content ID, DP host, BITS job ID, product/exit code | +| Updates | Scan, Evaluate, LocateSup, Download, MaintenanceWindow, Install, Reboot, Report | update/KB, CI ID, content ID, SUP host, update job/result ID | + +No timestamp alone can create a transaction key. An exact key can associate records only after the #318 extraction profile has identified the client version/artifact-family rule as validated. A time-only neighborhood may order a single artifact's local timeline but cannot establish a high-confidence relationship between separate artifacts or hosts. + +## Fixture Schema and Sanitization Contract + +Every client fixture directory contains exactly these committed inputs unless a scenario deliberately tests missing input: + +```text +manifest.json # schemaVersion, bundle metadata, all expected artifacts/states +evidence//.log # current and rotation fragments named by manifest relative paths +expected.json # expected transactions, findings, evidence refs, coverage, requests +README.md # only when scenario needs a specific explanation beyond directory name +``` + +`manifest.json` must declare, for every expected artifact: `artifactId`, `role: "client"`, `kind`, capture state, original basename, sanitized source path or `null`, rotation lineage, source ConfigMgr version or `null`, capture timestamp, and bounded byte count. `expected.json` must assert full output, not merely a title substring: + +```json +{ + "workflow": "policy", + "transactions": [{ + "transactionId": "policy:assignment:11111111-1111-1111-1111-111111111111", + "phase": "persist", + "state": "failed", + "lastSuccessfulPhase": "download", + "evidence": [{"artifactId": "client-policy-agent", "entryId": "entry-000001"}] + }], + "findings": [{ + "class": "confirmedFailure", + "confidence": "high", + "phase": "persist", + "coverageGapArtifactIds": [], + "nextArtifacts": [] + }] +} +``` + +Expected fixture outputs must be sorted by stable IDs. Never include a dynamically generated timestamp, random UUID, host identity, absolute temporary path, or an error description that comes from an unstable external database. When a fixture intentionally has incomplete coverage, it must assert the gap and bounded next-artifact request explicitly. + +## Source Bundle Contract for #319 + +### Initial client source groups + +The following source groups are the first curated client intake contract. The list is deliberately bounded; presence in a default directory is a candidate, not evidence that every client/version has that source. + +| Logical artifact ID | Candidate basenames | Primary purpose | Required for | Rotation behavior | +| --- | --- | --- | --- | --- | +| `client-ccmsetup` | `ccmsetup.log`, `client.msi.log` where documented | bootstrap/setup | health | current + `.lo_` + numbered/timestamped when captured | +| `client-evaluation` | `CcmEval.log`, `CcmExec.log`, `CcmRestart.log` | service/evaluation/restart | health | all recognized rotations | +| `client-identity` | `ClientIDManagerStartup.log` | client identity/registration | health | all recognized rotations | +| `client-location` | `ClientLocation.log`, `LocationServices.log`, `CcmMessaging.log` | site/MP/location/transport | health | all recognized rotations | +| `client-policy-agent` | `PolicyAgent.log`, `PolicyAgentProvider.log`, `PolicyEvaluator.log`, `Scheduler.log` | policy lifecycle | policy | all recognized rotations | +| `client-policy-state` | `CIAgent.log`, `CIDownloader.log`, `StateMessage.log`, `StatusAgent.log` | policy evaluation/reporting supplemental | policy | all recognized rotations | +| `client-app-intent` | `AppIntentEval.log`, `AppDiscovery.log` | app intent/requirements/detection | deployment | all recognized rotations | +| `client-app-enforce` | `AppEnforce.log`, `ExecMgr.log` | enforcement/result | deployment | all recognized rotations | +| `client-content` | `CAS.log`, `ContentTransferManager.log`, `DataTransferService.log`, `LocationServices.log` | location/content/transfer/cache | deployment | all recognized rotations | +| `client-updates` | `ScanAgent.log`, `WUAHandler.log`, `UpdatesDeployment.log`, `UpdatesHandler.log`, `UpdatesStore.log` | update lifecycle | updates | all recognized rotations | +| `client-windows-update-supplemental` | `ReportingEvents.log`, CBS/DISM artifact only when explicitly captured | OS update corroboration | updates | declared separately; never assume present | + +Paths for candidate discovery are platform/native concerns. Current client operational candidate roots include `%WINDIR%\\CCM\\Logs`, `%WINDIR%\\ccmsetup\\Logs`, and explicitly supplied alternate/cached paths. The pure catalog sees only artifact metadata and a basename; it does not reconstruct or assume a path. + +### Deterministic artifact/rotation rules + +- Capture order must be `(logical artifact ID, original path normalized for comparison, rotation rank, basename)` so bundle manifests are byte-stable for identical inputs. +- Rotation rank is `current`, then `.lo_`, then numeric/timestamped historical rotations in an explicitly documented oldest-to-newest or newest-to-oldest order. Choose one order, record it in the manifest, and normalize to chronological evidence ordering only after parsing timestamps. +- A same-basename collision from separate candidate roots must preserve a distinct `artifactId`/source-path fingerprint; it must never overwrite a file because the generic collector destination is filename-only. +- If a rotated fragment begins or ends mid-logical record, represent its coverage/parse boundary. It cannot emit a key, phase transition, or terminal finding by itself. +- Candidate access denied, cap reached, decoding failure, unsafe reparse point, or user-disabled optional source maps to a distinct SCCM capture state. It must not be recast as generic `Failed` without detail. +- A missing default root can only prove that this discovery attempt did not find it. It cannot prove the ConfigMgr role/client is absent or unhealthy. + +## Task 1: Establish #319's pure client intake catalog and fixture schema + +**Files:** + +- Create: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Create: `crates/cmtraceopen-parser/src/sccm/client/intake.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/mod.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/catalog.rs` +- Create: `crates/cmtraceopen-parser/tests/sccm_client_intake.rs` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/{complete,rotations,missing-root,access-denied,capped}/manifest.json` +- Create: matching `expected.json` and sanitized `evidence/` files for each nonempty scenario + +**Consumes:** The #318 public artifact, coverage, source classification, evidence-ref, timestamp, and schema-version contracts. + +**Produces:** A pure `assess_client_intake` API that describes what an already-supplied bundle covers; it neither reads from disk nor diagnoses a workflow. + +- [ ] **Step 1: Write the five intake fixture tests before creating client code** + +Write focused tests that deserialize the fixture manifest through the public SCCM bundle reader and assert these exact outcomes: + + - `complete`: all baseline health/policy/deployment/update source groups are `Captured`; output has zero absence-caused finding requests. + - `rotations`: `AppEnforce.log`, `AppEnforce.lo_`, and `AppEnforce.log.2` map to one logical client-app-enforce group with three ordered fragments and no filename collision. + - `missing-root`: no client root is discovered; every expected source group gets `Absent` and only an intake/coverage assessment, never "client not installed". + - `access-denied`: `client-policy-agent` is `AccessDenied`; policy readiness reports a bounded request for that group and does not emit a policy-failure diagnosis. + - `capped`: `client-content` is `Capped`; deployment readiness remains insufficient even when a retained tail contains an error-looking record. + +Use direct assertions rather than snapshots that silently bless new fields: + +~~~rust +#[test] +fn rotated_client_artifacts_have_one_logical_group_and_stable_lineage() { + let intake = load_client_intake_fixture("rotations"); + let group = intake.group("client-app-enforce").expect("group is catalogued"); + assert_eq!(group.coverage, SccmCoverageState::Captured); + assert_eq!(group.fragments.len(), 3); + assert_eq!(group.fragments[0].rotation, SccmRotation::Current); + assert_eq!(group.fragments[1].rotation, SccmRotation::LoUnderscore); + assert_eq!(group.fragments[2].rotation, SccmRotation::Numbered(2)); +} +~~~ + +- [ ] **Step 2: Run only the new test target and record its red failure** + +Run: + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_intake +~~~ + +Expected: FAIL because the `sccm::client` module, client-source groups, and fixture loader support do not exist. Do not implement any native discovery to make this green; the test must remain pure. + +- [ ] **Step 3: Add the pure catalog and intake projection** + +Define the client catalog in one location, preferably a table of `SccmSourceCatalogEntry` values extended in `sccm/catalog.rs`. Each entry declares logical artifact ID, role, artifact family, accepted basenames, workflow consumers, capture requiredness, and supported rotation names. `client/intake.rs` must: + + 1. normalize a supplied artifact basename and rotation without inspecting a path; + 2. match only catalogued client basenames; + 3. group captured fragments by logical artifact ID; + 4. retain unknown artifact entries as unknown/unsupported evidence rather than dropping them; + 5. compute group coverage as the most limiting meaningful state, while preserving every fragment state in the result; + 6. return stable sorted groups and coverage gaps. + +Do not implement source discovery, filename globbing, or a new parser in this step. The source catalog must map `ccmsetup` separately from operational `CCM\\Logs` sources; `ccmsetup` is not a substitute for client operational logs. + +- [ ] **Step 4: Make tests green and add negative contract tests** + +Add assertions that: + + - `CustomVendorHook.log` is represented as unsupported/unknown and does not become a client-policy log; + - a `.lo_` suffix is recognized only as a rotation of its explicit base name; + - a file named `PolicyAgent.log.backup` is not silently treated as a known rotation; + - different source paths containing an identical basename remain separate fragments; + - reordering manifest artifacts gives byte-identical serialized assessment output. + +Run: + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_intake +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +~~~ + +Expected: PASS. + +- [ ] **Step 5: Commit the pure intake contract in isolation** + +~~~bash +git add crates/cmtraceopen-parser/src/sccm crates/cmtraceopen-parser/tests/sccm_client_intake.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client +git commit -m "feat(sccm): define client intake coverage contract" +~~~ + +Do not include `src-tauri` changes in this commit. Link the exact fixture matrix and test command in #319 after review. + +## Task 2: Implement #319 native bounded client discovery and SCCM manifest v1 + +**Files:** + +- Modify: `src-tauri/Cargo.toml` +- Modify: `src-tauri/src/lib.rs` +- Create: `src-tauri/src/sccm/mod.rs` +- Create: `src-tauri/src/sccm/intake.rs` +- Create: `src-tauri/src/sccm/bundle.rs` +- Create: `src-tauri/src/sccm/manifest.rs` +- Create: `src-tauri/tests/sccm_client_intake.rs` +- Modify only if a proven shared helper is necessary: `src-tauri/src/esp/discovery.rs` + +**Consumes:** The pure #319 catalog/coverage contract and native bounded discovery primitives in `src-tauri/src/esp/discovery.rs` as an internal reference. + +**Produces:** A native, feature-gated client capture adapter that writes a versioned SCCM manifest without changing generic collection semantics or creating a Tauri UI command. + +- [ ] **Step 1: Write temp-directory discovery/manifest failures first** + +Add `[[test]]` to `src-tauri/Cargo.toml`: + +~~~toml +[[test]] +name = "sccm_client_intake" +required-features = ["sccm-diagnostics"] +~~~ + +Add the feature as an opt-in native feature (`sccm-diagnostics = []` initially; add dependencies only when a tested implementation requires them). Test with a fake discovery input rooted in a temporary directory, never `C:\\Windows`: + + 1. captures current, `.lo_`, and numeric rotated files into collision-safe relative bundle paths; + 2. serializes `sccmManifestVersion: 1`, host/role/source path/rotation/capture state/byte count for every expected artifact; + 3. emits `Absent`, `AccessDenied`, `Capped`, and `Skipped` in the SCCM extension with deterministic ordering; + 4. rejects a symlink/reparse target escaping the supplied discovery root; + 5. maps a legacy generic manifest's `collected`, `missing`, and `failed` values only to documented legacy-compatible views, preserving an "unknown detail" gap for failed. + +- [ ] **Step 2: Prove the tests fail before adding native module code** + +Run: + +~~~bash +cargo test --locked -p cmtrace-open --test sccm_client_intake --features sccm-diagnostics +~~~ + +Expected: FAIL due to missing feature/test/module surface. If the test cannot compile because the feature is unknown, add only the Cargo feature/test registration, rerun, and retain the next missing-symbol failure as the red state. + +- [ ] **Step 3: Add narrowly-scoped native discovery and writing APIs** + +Implement the following native-only responsibilities: + +~~~rust +pub fn discover_client_sources( + input: &SccmClientDiscoveryInput, +) -> SccmClientDiscoveryResult; + +pub fn capture_client_bundle( + request: &SccmClientCaptureRequest, +) -> Result; + +pub fn write_sccm_manifest_v1( + bundle_root: &Path, + manifest: &SccmBundleManifestV1, +) -> Result<(), AppError>; +~~~ + +`SccmClientDiscoveryInput` must expose candidate roots, a maximum file count/bytes per logical source, an allow-listed source catalog view, and a testable access-status provider. It must not read arbitrary paths passed from the frontend. Resolve/canonicalize each candidate root before enumerating it, reject a path outside the approved root, and preserve the original configured path only as privacy-classified manifest provenance. + +The writer must use a dedicated file such as `sccm-manifest.json` or an additive namespaced object recognized by a versioned reader. Do not modify `src-tauri/src/collector/manifest.rs` to add new enum meanings unless a separate compatibility PR first expands generic result models and all existing consumers. The SCCM bundle's evidence layout must preserve logical source ID and unique fragment identity, for example: + +```text +evidence/sccm/client/client-app-enforce/current/AppEnforce.log +evidence/sccm/client/client-app-enforce/lo/AppEnforce.lo_ +evidence/sccm/client/client-app-enforce/numbered-2/AppEnforce.log.2 +``` + +- [ ] **Step 4: Verify deterministic capture and existing native regression behavior** + +Run: + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtrace-open --test sccm_client_intake --features sccm-diagnostics +cargo test --locked -p cmtrace-open --test esp_diagnostics_sources --all-features +cargo test --locked -p cmtrace-open --test parser_expanded_corpus --all-features +cargo clippy --locked -p cmtrace-open --all-targets --all-features -- -D warnings +git diff --check +~~~ + +Expected: PASS on the development host for temp-directory behavior. The ESP suite remains a regression signal; do not move SCCM tests into its large source file. + +- [ ] **Step 5: Commit native intake separately and write the live-lab validation checklist** + +~~~bash +git add src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/src/sccm src-tauri/tests/sccm_client_intake.rs +git commit -m "feat(sccm): capture bounded client diagnostic bundles" +~~~ + +Before a Windows client run, record in #319 or its linked validation checklist: ConfigMgr client version, Windows version, client install path if non-default, selected candidate roots, capture limit, time zone, intentionally generated lab workflow, and redaction proof. Do not capture production/customer evidence just to make a fixture. + +## Task 3: Validate #319 on a Windows SCCM Client without making the lab a blocker + +**Files:** + +- Create: `docs/sccm/validation/client-intake-lab-checklist.md` +- Modify: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md` only with sanitized observed-version notes +- Modify: GitHub issue #319 after validation evidence exists + +**Consumes:** #319 pure and native passing tests, an authorized development SCCM Client, and a consciously selected synthetic scenario. + +**Produces:** Reproducible collection validation evidence; no parser behavior change unless a sanitized, independently reproducible discrepancy warrants a follow-up issue. + +- [ ] **Step 1: Create a checklist before connecting to the lab** + +The checklist must require confirmation of these read-only facts before capture: + + - client host is a development/test machine and not a customer endpoint; + - ConfigMgr client version and site code are recorded in a sanitized form; + - expected client root(s) and alternate paths are observed, not assumed; + - no credentials, enrollment tokens, certificates, live user context, or secret-bearing command output are in the selected bundle; + - bundle size/file limits and the rationale are recorded; + - a synthetic policy, deployment, or update workflow is chosen only if it is safe in the lab; + - temporary captured evidence location and retention/disposal owner are documented. + +- [ ] **Step 2: Run the native capture in dry-run/discovery mode first** + +Use the native API or a narrowly scoped test harness to list discovered candidates and predicted coverage without copying files. Compare candidate names and rotations to the catalog. A source missing from its expected default root is a discovery result to record, not a code defect until a configured/observed path proves it should be captured. + +- [ ] **Step 3: Capture one bounded synthetic scenario and verify manifest facts** + +Assert manually and with a test harness that the written manifest retains source group, role `client`, relative path, original basename, coverage state, rotation, byte count, collection time, and redacted/no-sensitive provenance. Confirm distinct same-name files do not overwrite one another. Confirm a deliberately unreadable test path yields `AccessDenied` or a documented simulation outcome rather than `Missing`. + +- [ ] **Step 4: Convert only sanitized minimal evidence into fixtures** + +Copy no lab log wholesale. Reduce each approved scenario to the smallest synthetic records that demonstrate the contract. Preserve line/rotation/timestamp relationships, replace identity values consistently, and add a fixture README stating that all values are synthetic. Rerun the parser suite after the fixture is committed. + +- [ ] **Step 5: Report the gate outcome accurately** + +Post a #319 comment with OS/ConfigMgr version family, source catalog confirmation, test commands, manifest schema version, coverage states exercised, redaction result, and any unvalidated path/rotation behavior. If native Windows validation cannot run yet, leave #319 open with pure/native temp-directory tests green; do not claim real capture acceptance. + +## Task 4: Implement #320 client setup, health, identity, and location analysis + +**Files:** + +- Create: `crates/cmtraceopen-parser/src/sccm/client/health.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Create: `crates/cmtraceopen-parser/tests/sccm_client_health.rs` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/{success,setup-failure,identity-failure,no-site-or-mp,transport-failure,rotation-boundary,incomplete}/manifest.json` +- Create: matching `evidence/` and `expected.json` assets + +**Consumes:** #318 normalized evidence/signals/keys/findings and #319 source groups `client-ccmsetup`, `client-evaluation`, `client-identity`, and `client-location`. + +**Produces:** A health/location state machine that identifies the last evidenced good hop and requests the next smallest client artifact when setup, identity, site assignment, MP location, or transport evidence is absent. + +### Health state contract + +```text +Setup -> Service -> Identity -> SiteAssignment -> ManagementPoint -> Transport +``` + +`Setup` means the client installation/bootstrap record is evidenced, not merely that `ccmsetup.log` exists. `Service` means a service/evaluation/restart observation is evidenced. `Identity` means a client identity/registration outcome is evidenced. `SiteAssignment` and `ManagementPoint` require their own client-location evidence. `Transport` requires a completed request/response or a terminal transport error for the same validated key/context. Do not infer site/MP success from a hostname-shaped string in unrelated message text. + +- [ ] **Step 1: Add behavior-first health tests** + +Create one test per fixture and assert exact phase/class/confidence/evidence/next request. Minimum cases: + + - a complete setup-to-transport sequence returns no failure finding and records `Transport` as last successful; + - a setup terminal error creates `ConfirmedFailure` only if terminal evidence is present and no later successful bootstrap proves recovery; + - identity registration failure is not mislabeled as MP failure; + - missing/empty `ClientLocation.log` after an evidenced identity requests the location artifact and yields `InsufficientEvidence`; + - no site/MP evidence returns a bounded `SiteAssignment` or `ManagementPoint` gap, not an assertion that the client is unassigned; + - a same-minute generic network error with no validated request/host key creates only a low-confidence symptom; + - a record split across rotations cannot advance or fail the state machine. + +Use a failing public call first: + +~~~rust +let result = analyze_client_health(&load_bundle("health/no-site-or-mp")); +assert_eq!(result.last_successful_phase, Some(SccmPhase::Identity)); +assert_eq!(result.findings[0].class, SccmFindingClass::InsufficientEvidence); +assert_eq!(result.findings[0].next_artifacts[0].logical_artifact_id, "client-location"); +~~~ + +- [ ] **Step 2: Run the health target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_health +~~~ + +Expected: FAIL because `health.rs` and its state transitions do not exist. + +- [ ] **Step 3: Implement a finite, evidence-first reducer** + +Use a private ordered reducer over `SccmEvidence` that accepts catalogued source groups only. It may advance a phase on a positive, profile-validated record; it may mark a terminal failure only on a profile-validated terminal record; it must keep alternative/contradictory records as evidence. Do not use a single mutable global "client health" state across artifacts. Sort records by resolved UTC only when timestamp provenance permits it; otherwise retain source-local order and lower cross-artifact confidence. + +- [ ] **Step 4: Make the test target and general parser suite green** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_health +cargo test --locked -p cmtraceopen-parser --test sccm_client_intake +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +~~~ + +- [ ] **Step 5: Commit #320 without folding policy/deployment logic into it** + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/client crates/cmtraceopen-parser/tests/sccm_client_health.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/health +git commit -m "feat(sccm): analyze client health and location evidence" +~~~ + +## Task 5: Implement #321 policy acquisition, evaluation, and reporting analysis + +**Files:** + +- Create: `crates/cmtraceopen-parser/src/sccm/client/policy.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Create: `crates/cmtraceopen-parser/tests/sccm_client_policy.rs` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/{complete,request-auth-failure,download-failure,persist-failure,scheduler-deferred,evaluation-failure,reporting-failure,rotation-split,malformed,incomplete}/{manifest.json,expected.json,evidence/}` + +**Consumes:** Client policy source groups, #318 versioned assignment/client/site/request keys, #320 health/location findings only as a cited prerequisite—not as a replacement for policy evidence. + +**Produces:** Per-policy/assignment transaction analysis across request, download, persist, schedule, evaluate, and report phases. + +### Policy state contract + +```text +Request -> Download -> Persist -> Schedule -> Evaluate -> Report +``` + +An assignment transaction exists only with an exact/validated assignment or policy key, or a deliberately declared keyless single-artifact local observation that is forced to low confidence and cannot be correlated later. Do not collapse all policy messages into one device-wide transaction. The analyzer must preserve a `Deferred` state separately from terminal failures—for example, a scheduler wait is not a failed evaluation. + +- [ ] **Step 1: Write failing transaction tests for all policy terminal classes** + +For each scenario, assert transaction ID, last success, state, class, evidence refs, and next artifact request. Include: + + - complete policy flow with no failure; + - request authentication/transport failure with a requested `client-location` artifact if location coverage is missing; + - transfer/download failure with no unsupported inference about MP behavior; + - persistence failure with a terminal client record; + - scheduler deferred / maintenance or retry state as `BlockedOrDeferred`, not `ConfirmedFailure`; + - evaluation failure after an evidenced schedule; + - reporting failure after successful evaluation; + - rotation-split correlation key that cannot create a policy transaction; + - malformed or unknown-version policy message that retains a low-confidence symptom and requests a bounded source; + - missing state/report artifact produces explicit coverage rather than "policy succeeded". + +- [ ] **Step 2: Run #321 tests red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_policy +~~~ + +Expected: FAIL before `analyze_client_policy` exists. + +- [ ] **Step 3: Implement keyed, isolated policy reducers** + +Group evidence by `AssignmentId`/policy ID only when `SccmKeyConfidence` satisfies the profile's exact/strong threshold. For each group, order the safe evidence timeline, advance the phase monotonicly, preserve retries as repeated observations, and emit a single final transaction state. A later explicit success may supersede an earlier terminal-looking record only when it has the same validated key and coherent evidence ordering; otherwise produce contradictory/low-confidence evidence, not silent recovery. + +Construct findings through the #318 builder. A high-confidence failure needs terminal or corroborating evidence. Any partial group must generate the smallest source request from the policy source catalog: `client-policy-agent` for missing request/download/persist/schedule records; `client-policy-state` for missing evaluate/report state. + +- [ ] **Step 4: Add deterministic and false-causality regression cases** + +Assert that reordering artifacts produces identical serialized analysis; an unrelated client policy error with an unrelated assignment does not affect the target transaction; same timestamps with different keys stay separate; client-only output never claims an MP-side root cause; and absent ConfigMgr version cannot create an exact extracted key. + +Run: + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_policy +cargo test --locked -p cmtraceopen-parser --test sccm_client_health +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +~~~ + +- [ ] **Step 5: Commit the policy slice and document the #333 handoff** + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/client crates/cmtraceopen-parser/tests/sccm_client_policy.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy +git commit -m "feat(sccm): analyze client policy transactions" +~~~ + +In #321, record the validated policy keys/version profile and explicitly link its output contract as a prerequisite for #333 policy-to-MP correlation. Do not implement MP rules here. + +## Task 6: Implement #322 application, package, and content deployment analysis + +**Files:** + +- Create: `crates/cmtraceopen-parser/src/sccm/client/deployment.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Create: `crates/cmtraceopen-parser/tests/sccm_client_deployment.rs` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/{success,not-targeted,requirements-failure,dependency-failure,location-missing,dp-content-missing,bits-transfer-failure,cache-failure,enforcement-exit,detection-false-negative,rotation-boundary,incomplete}/{manifest.json,expected.json,evidence/}` + +**Consumes:** Client app/content groups, versioned assignment/CI/package/content/DP/BITS/product/exit keys, and shared signal extraction. Existing MSI/PSADT/Burn parser outputs may be attached only as separately classified supplemental artifacts. + +**Produces:** Per-deployment transaction analysis that distinguishes target/intent, requirements/dependencies, location/content, transfer/cache, enforcement, detection, and state reporting. + +### Deployment state contract + +```text +Intent -> Requirements -> LocateContent -> Transfer -> Cache -> Enforce -> Detect -> Report +``` + +The transaction key priority is: exact assignment+CI, then exact package/content with a corroborating assignment/CI, then a bounded local candidate with low confidence. Do not key a deployment by filename, `AppEnforce` component, deployment display name, or time alone. `NotTargeted` is a classification only when explicit policy/intent evidence says the assignment is not applicable; a missing intent log is insufficient evidence. + +- [ ] **Step 1: Write the deployment fixture tests before the reducer** + +Assert these outcomes: + + - success retains final detected/reported evidence and no failure; + - explicit not-targeted is not a failure and does not request DP evidence; + - requirement/dependency failure stops before location and does not call it a download issue; + - missing content location request is client-side insufficient evidence unless an exact client content error is terminal; + - an exact content/DP request with a missing client content response is a `LocateContent` gap, not a DP diagnosis; + - BITS/transfer failure cites transfer evidence and preserves the BITS signal/key; + - cache failure remains distinct from transfer failure; + - enforcement nonzero exit code is a symptom until terminal app-enforcement/result record corroborates it; + - a detected-state mismatch after enforcement is a detection result, not an installation root cause; + - malformed/rotation-split or incomplete source coverage yields low confidence/next artifacts; + - an MSI, PSADT, or Burn supplemental artifact may enrich a same-key client deployment but cannot override the SCCM phase absent a stable key. + +- [ ] **Step 2: Run the focused target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_deployment +~~~ + +Expected: FAIL because deployment source grouping and reducer do not exist. + +- [ ] **Step 3: Implement source-local facts, then keyed reducer composition** + +In `deployment.rs`, make small private functions that extract facts from each source family (intent, discovery, enforcement, content location, transfer, cache, supplemental installer). Each fact retains `SccmEvidenceRef`, exact keys, phase candidate, and terminality. Compose facts into transactions only after the #318 key/profile check succeeds. Do not allow a generic error token from `DataTransferService.log` to attach to every app deployment. + +Use stable sorted `BTreeMap`/sort keys. Preserve parallel deployments as separate transactions. When an artifact has only an unsafe candidate key, expose it as a low-confidence unlinked symptom and request the minimum related source, not a broad "collect all SCCM logs" request. + +- [ ] **Step 4: Verify independent and shared contracts** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_deployment +cargo test --locked -p cmtraceopen-parser --test sccm_client_policy +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +~~~ + +- [ ] **Step 5: Commit #322 and preserve the server handoff boundary** + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/client crates/cmtraceopen-parser/tests/sccm_client_deployment.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment +git commit -m "feat(sccm): analyze client deployment and content evidence" +~~~ + +Update #322 with its client-only limitations. The only #333 handoff is stable cited client content/DP keys plus phase facts; no statement about a distribution point cause belongs in #322. + +## Task 7: Implement #323 software-update analysis + +**Files:** + +- Create: `crates/cmtraceopen-parser/src/sccm/client/updates.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Create: `crates/cmtraceopen-parser/tests/sccm_client_updates.rs` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/{success,no-sup,scan-failure,evaluation-failure,content-failure,maintenance-window,reboot-pending,install-failure,reporting-failure,supplemental-conflict,incomplete}/{manifest.json,expected.json,evidence/}` + +**Consumes:** Client update sources, shared versioned update/KB/CI/content/SUP/job keys, and optional separately captured CBS/DISM/ReportingEvents artifacts. + +**Produces:** Per-update transaction analysis that identifies the last proven stage from scan through report, while keeping ConfigMgr client evidence separate from Windows servicing supplemental evidence. + +### Update state contract + +```text +Scan -> Evaluate -> LocateSup -> Download -> MaintenanceWindow -> Install -> Reboot -> Report +``` + +`LocateSup` means the client has an evidenced SUP/location interaction; it does not prove the SUP server was healthy. `MaintenanceWindow` and `Reboot` are blocked/deferred outcomes unless terminal evidence proves a failure. CBS/DISM/ReportingEvents can corroborate an install/reboot outcome only when source provenance and a stable update/KB/CI key permit it. Their presence cannot turn a client-only update flow into a server diagnosis. + +- [ ] **Step 1: Write failing fixture tests for each update branch** + +Required scenarios: + + - full success with report; + - no SUP/location evidence after scan/evaluate requests the appropriate client source and returns insufficient evidence; + - scan failure; + - evaluation failure; + - content/download failure; + - maintenance-window delay is `BlockedOrDeferred` with its next time/context evidence requested only if absent; + - reboot-pending is `BlockedOrDeferred`, not install failure; + - terminal install failure with exact update key; + - reporting failure after install success; + - contradictory CBS/DISM supplemental evidence with no exact key remains a low-confidence symptom; + - incomplete/malformed rotation coverage produces no cause. + +- [ ] **Step 2: Run the update test target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_updates +~~~ + +Expected: FAIL before the update reducer exists. + +- [ ] **Step 3: Implement keyed update fact extraction and phase reduction** + +Use source-specific fact extractors for `ScanAgent`, `WUAHandler`, `UpdatesDeployment`, `UpdatesHandler`, and `UpdatesStore`. Only attach supplemental windows-servicing facts after the update/KB/CI key match and source/version profile requirements have passed. Model overlapping updates separately. A signal code alone can describe a terminal error only when the source-specific update fact recognizes a terminal status; the generic #318 signal extractor cannot decide this for the reducer. + +- [ ] **Step 4: Add conservative ordering/coverage regressions and run gates** + +Add tests that invalid/missing offset disallows cross-artifact high confidence, multiple updates in the same minute do not merge, an absent SUP-log counterpart does not blame a server, and artifact input order does not alter JSON output. + +Run: + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_client_updates +cargo test --locked -p cmtraceopen-parser --test sccm_client_deployment +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +~~~ + +- [ ] **Step 5: Commit #323 independently** + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/client crates/cmtraceopen-parser/tests/sccm_client_updates.rs crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates +git commit -m "feat(sccm): analyze client software update transactions" +~~~ + +The #323 completion comment must list the validated client-only update keys and note that server SUP correlation remains deferred until #330 and #333 validate a pairwise contract. + +## Task 8: Run the client-core release gate and issue evidence pass + +**Files:** + +- Modify: `crates/cmtraceopen-parser/README.md` only if the implemented public API requires a concise SCCM Client usage example +- Modify: GitHub issues #319–#323 with completion/test/fixture evidence + +**Consumes:** All prior tasks in this plan, the #318 contract suite, and available development client validation information. + +**Produces:** Review-ready individual issue evidence with a clear list of unvalidated live-Windows behaviors. + +- [ ] **Step 1: Execute focused parser and native suites** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo test --locked -p cmtraceopen-parser --test sccm_client_intake +cargo test --locked -p cmtraceopen-parser --test sccm_client_health +cargo test --locked -p cmtraceopen-parser --test sccm_client_policy +cargo test --locked -p cmtraceopen-parser --test sccm_client_deployment +cargo test --locked -p cmtraceopen-parser --test sccm_client_updates +cargo test --locked -p cmtraceopen-parser +cargo test --locked -p cmtrace-open --test sccm_client_intake --features sccm-diagnostics +cargo test --locked -p cmtrace-open --test esp_diagnostics_sources --all-features +cargo test --locked -p cmtrace-open --test parser_expanded_corpus --all-features +~~~ + +- [ ] **Step 2: Execute compilation, style, and compatibility gates** + +~~~bash +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo clippy --locked -p cmtrace-open --all-targets --all-features -- -D warnings +cargo fmt --check --all +git diff --check +~~~ + +- [ ] **Step 3: Inspect the shipped JSON contracts deliberately** + +For one success, one terminal failure, one deferred, and one incomplete fixture per workflow, serialize the public analysis and inspect: camelCase names; schema version; deterministic array order; bounded requested artifacts; evidence IDs; no raw context/user/path beyond approved redaction; no server causal wording; and no unknown signal loss. Remove any temporary debug output before committing. + +- [ ] **Step 4: Post issue-specific evidence rather than a generic program update** + +For #319, post catalog version, manifest schema, rotation/collision/access/cap scenarios, Windows validation state, and exact tests. For #320–#323, post source groups, state phases, fixture scenario names, version profiles/keys, expected conservative behavior, exact tests, and the next correlation prerequisite. Leave an issue open whenever its Windows/native acceptance gate or an explicit required fixture remains incomplete. + +- [ ] **Step 5: Use review boundaries, not a mega-PR** + +Keep one PR/commit series per issue (or split pure/native portions of #319). Request review of #318 contract compatibility before #319; request a separate false-causality review for #321/#322/#323 transactions. Never close #319–#323 merely because the code compiles: every closure needs the linked test corpus and the defined exit conditions below. + +## Per-Issue Exit Criteria + +### #319 Client intake + +- [ ] Pure catalog handles all listed client groups, unknown source names, current/.lo_/numbered/timestamped rotations, and deterministic ordering. +- [ ] Native SCCM manifest v1 preserves group/role/path/host/rotation/state/size provenance without changing generic manifest semantics. +- [ ] Collision, absent, access-denied, capped, skipped, unsafe-path, and legacy mapping tests pass. +- [ ] Windows client validation is either recorded passing with a sanitized lab artifact or explicitly listed as pending; no false claim of native acceptance. + +### #320 Health/location + +- [ ] Every phase has success, terminal failure, contradictory, and incomplete evidence tests. +- [ ] Findings cite exact evidence and last known good phase; no failure classification based solely on absence. +- [ ] Location/MP claims are client-side observations only until #333 validates cross-side evidence. + +### #321 Policy + +- [ ] Transactions are keyed conservatively and handle deferred/retry/contradictory/rotation cases. +- [ ] Request/download/persist/schedule/evaluate/report gaps request the smallest specific artifact group. +- [ ] Client-only policy findings do not assert an MP cause. + +### #322 Deployment/content + +- [ ] Intent/requirements/location/transfer/cache/enforce/detect/report phases remain distinct. +- [ ] Same-minute/multi-deployment/unkeyed installer cases do not merge or establish high confidence. +- [ ] Output contains the exact client keys/evidence #333 needs for future content-to-DP correlation, and no DP-side cause claim. + +### #323 Updates + +- [ ] Scan/evaluate/SUP-location/download/MW/install/reboot/report phases are distinct with all branch fixtures. +- [ ] Supplemental Windows servicing logs are strictly provenance/key gated. +- [ ] Client-only evidence does not assert SUP/server health; it names missing counterpart evidence when appropriate. diff --git a/docs/superpowers/plans/2026-07-30-sccm-cross-side-correlation.md b/docs/superpowers/plans/2026-07-30-sccm-cross-side-correlation.md new file mode 100644 index 000000000..fc8cf140b --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-sccm-cross-side-correlation.md @@ -0,0 +1,492 @@ +# SCCM Cross-Side Correlation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Deliver issue #333 as a conservative client/server correlation layer. The first shipped pairs are policy to Management Point and content to Distribution Point. A later updates to SUP pair is gated rather than assumed. + +**Architecture:** Correlation consumes normalized, role-classified SCCM evidence and workflow outputs from #318, #321/#322, and #328/#329. It builds deterministic links only when versioned exact keys, compatible topology, usable timestamp provenance, and corroborating phase or terminal evidence justify them. It returns cited links, last-known-good hops, coverage requests, and symptoms or diagnoses; it never overwrites a source analyzer, calls the network, or converts adjacent timestamps into a root cause. + +**Tech Stack:** Rust 1.88, cmtraceopen-parser pure crate, serde/serde_json, BTreeMap/stable sorting, shared SCCM evidence/finding models, synthetic client/server fixture bundles. No Windows/native collection code is required beyond manifest provenance preserved by #319 and #335. + +## Global Constraints + +- #318 is required. Policy to MP starts only after #321 and #328 have stable public facts, keys, and fixtures. Content to DP starts only after #322 and #329 have the same. Do not make #333 wait for #330 or advanced-role work. +- This plan implements pairwise correlation only. It does not replace client/server source analyzers, add raw parsers, add a ParserKind, implement a graph database, perform live server queries, or create a workspace UI. +- Correlation must consume complete logical records and existing evidence references. It never reparses physical lines or extracts keys from a rotation fragment rejected by #318. +- Use exact profile-validated keys plus compatible topology for high-confidence joining. Time-only, filename-only, generic error-code, component-name, or same-host-name joins are not causal proof. +- A valid UTC value requires valid source offset/provenance. Missing/invalid offsets prevent cross-host causal ordering; they may only support a low-confidence local-time observation if the pair rule explicitly allows one. +- Link source artifact identity, source code file attribute, role, host/topology, path class, and capture state remain distinct. A matching CCM file= attribute never means matching captured artifact. +- Client/server keys that could carry identity or sensitive path/URL/context values must use the #318 redacted stable handle. Raw values must never appear in public link/finding/export JSON. +- A client-only or server-only bundle is a supported input. The result must identify what it can prove and request the minimum counterpart artifact/role, not return an empty result or assert a cause. +- A direct client finding and a server finding continue to exist independently. Correlation adds links and higher-confidence cross-side findings only when strict requirements pass; it must not silently rewrite source-side confidence. +- Any new pair after policy-MP/content-DP requires a source contract, pair-specific fixture matrix, and separate issue/PR or clearly scoped #333 subtask. Do not generalize from one pair to every SCCM workflow. +- Output order, link IDs, candidate explanations, coverage requests, and redacted projection must be deterministic under artifact/evidence input reordering. + +--- + +## Dependency and Rollout Map + +~~~text +#318 normalized evidence + versioned keys + coverage + redaction + | + +--> #321 policy client facts ----+ + | +--> #333 policy <-> MP pair + +--> #328 MP server facts --------+ + | + +--> #322 deployment/content facts -+ + | +--> #333 content <-> DP pair + +--> #329 DP server facts ----------+ + | + +--> #323 updates + #330 SUP ----> future pair only after a new reviewed subplan +~~~ + +The first two pairs are deliberately independent. Land a generic topology/link contract first, then policy-MP and content-DP as separate commits/tests. Each pair receives a false-causality review on its own. If #321/#328 or #322/#329 changes a public key/provenance contract, amend that upstream plan before implementing a workaround in #333. + +## File Structure and Ownership + +~~~text +crates/cmtraceopen-parser/ +├── src/sccm/ +│ ├── models.rs # shared correlation wire types only if #318 owns them +│ ├── findings.rs # shared validation; do not duplicate it here +│ └── correlation/ +│ ├── mod.rs # public correlation facade +│ ├── topology.rs # role/host/site/path compatibility checks +│ ├── link.rs # generic deterministic link candidate builder/ranker +│ ├── rules.rs # common evidence/coverage/confidence guards +│ ├── policy_management_point.rs # #321 + #328 pair rules +│ └── content_distribution_point.rs # #322 + #329 pair rules +├── tests/ +│ ├── sccm_correlation_contract.rs +│ ├── sccm_correlation_policy_management_point.rs +│ ├── sccm_correlation_content_distribution_point.rs +│ └── fixtures/sccm/correlation/ +│ ├── README.md +│ ├── shared// +│ ├── policy_management_point// +│ └── content_distribution_point// +~~~ + +No files in src-tauri are needed for semantic correlation. Native work merely preserves manifest topology, role, host, path, and coverage needed by these pure inputs. Do not add cross-side logic to the client or server intake modules; source modules expose safe facts and correlation owns joins. + +## Public Correlation Contract + +Expose one small public entry point and a serializable result. Exact field names belong to the #318 schema review, but the behavior contract is fixed here: + +~~~rust +pub fn correlate_client_server( + bundle: &SccmNormalizedBundle, +) -> SccmCorrelationResult; + +pub struct SccmCorrelationResult { + pub schema_version: u32, + pub links: Vec, + pub findings: Vec, + pub coverage_gaps: Vec, + pub artifact_requests: Vec, +} + +pub struct SccmCorrelationLink { + pub link_id: String, + pub workflow: SccmCorrelationWorkflow, + pub strength: SccmLinkStrength, + pub topology: SccmTopologyCompatibility, + pub matched_keys: Vec, + pub client_evidence: Vec, + pub server_evidence: Vec, + pub ordering: SccmCorrelationOrdering, + pub reason: String, +} +~~~ + +Required link strengths and their maximum conclusions: + +| Link strength | Minimum proof | Maximum output | +| --- | --- | --- | +| ExactCorroborated | exact validated keys, compatible topology, usable ordering when order is asserted, terminal/corroborating facts | high-confidence cross-side diagnosis or confirmed last good hop | +| ExactPartial | exact validated keys but missing coverage/terminal/ordering evidence | linked symptom, low/medium contributor, specific counterpart request | +| Candidate | compatible role plus low-confidence candidate key or time neighborhood | low-confidence candidate/symptom only; never root cause | +| Incompatible | conflicting keys/topology/version/role | no causal link; optional diagnostic explanation/coverage request | +| Unlinked | no safe association | source-local analysis remains; bounded counterpart request only when it resolves a concrete question | + +The code must prevent a caller from constructing a high-confidence cross-side finding from Candidate, Incompatible, or Unlinked strength. This is a testable validation invariant, not reviewer convention. + +## Pairwise Evidence Requirements + +### Policy to Management Point + +Client #321 emits validated policy/assignment/request/client/site/MP facts with phase and evidence refs. Server #328 emits validated request/policy/client/site/MP facts with phase and evidence refs. A high-confidence link requires: + +1. exact common policy/assignment/request key according to a shared profile; +2. compatible site/MP topology, including selected/observed MP where evidence permits; +3. client request/response and server receive/auth/policy/response facts that do not contradict each other; +4. valid ordering provenance whenever the finding claims first failed hop or client-before-server sequence; +5. sufficient required client and MP source coverage; +6. terminal/corroborating evidence for a cross-side confirmed failure. + +If an exact policy key matches but the MP host/site is incompatible, emit Incompatible and explain topology mismatch without blaming either host. If the client has a request failure and no MP capture, return client-side fact plus a request for the named MP artifact group, not an MP failure. + +### Content to Distribution Point + +Client #322 emits validated assignment/CI/package/content/version/DP/transfer facts. Server #329 emits package/content/version/DP distribution/validation/serve facts. A high-confidence link requires: + +1. exact normalized content/package identity plus version where the profile says version is significant; +2. compatible DP topology/host or explicit distribution mapping; +3. client location/transfer and server content availability/serve facts that belong to the same content/DP; +4. usable ordering when sequencing is asserted; +5. required client-content and DP coverage; +6. terminal/corroborating evidence for a cross-side confirmed failure. + +Matching content ID but a different version or DP is Incompatible, not a weak success/failure. A client BITS/cache/enforcement failure with no compatible DP fact stays client-local. A DP distribution error with no client request stays server-local. + +## Task 1: Add cross-side models and negative-contract tests + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/correlation/mod.rs +- Create: crates/cmtraceopen-parser/src/sccm/correlation/rules.rs +- Modify: crates/cmtraceopen-parser/src/sccm/mod.rs +- Modify: crates/cmtraceopen-parser/src/sccm/models.rs only when #318 has approved shared correlation wire types +- Create: crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +- Create: crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md +- Create shared fixtures client-only, server-only, same-time-no-key, conflicting-key, invalid-offset, unknown-profile, rotation-split, reordered-input, and redaction + +**Consumes:** #318 public evidence/coverage/key/timestamp/finding/redaction contracts and stable upstream workflow fact interfaces. + +**Produces:** Correlation model/API skeleton and non-negotiable safety validation before any workflow pair logic exists. + +- [ ] **Step 1: Write failing public-import and safety tests** + +Test that public API/result types exist, carry a schema version, and preserve deterministic ordering. More importantly, write tests that must fail until guards exist: + + - Candidate, Incompatible, and Unlinked links cannot build a High confidence ConfirmedFailure; + - a link with missing/invalid timestamp offset cannot claim causal ordering; + - an exact key from an unknown/unvalidated extraction profile cannot be promoted to ExactCorroborated; + - raw user/context/path/token-like test markers do not appear in redacted result JSON; + - client-only and server-only inputs produce coverage/artifact request output with no fabricated cross-side finding; + - reordering artifact/evidence input produces identical serialized output. + +- [ ] **Step 2: Run the contract target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_contract +~~~ + +Expected: FAIL because correlation modules/types/validation do not exist. + +- [ ] **Step 3: Implement minimal types, facade, and validation invariants** + +Create private generic constructors/rules in rules.rs and public re-exports in mod.rs. The initial correlation function may return source-independent coverage/no-link results, but it must not add pair behavior. Use shared SccmFindingBuilder validation or extend it in one controlled shared change; do not copy finding validation into the correlation directory. + +Use deterministic identifiers based on schema version, workflow, sorted safe keys, stable evidence IDs, and topology handles. Do not use wall clock/random UUIDs. Make a public redacted export projection immutable: projection must not mutate the original result/snapshot. + +- [ ] **Step 4: Make contract tests green** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_contract +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +~~~ + +- [ ] **Step 5: Commit cross-side safety boundary** + +~~~bash +git add crates/cmtraceopen-parser/src/sccm crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs crates/cmtraceopen-parser/tests/fixtures/sccm/correlation +git commit -m "feat(sccm): add correlation safety contract" +~~~ + +## Task 2: Implement topology compatibility and generic link construction + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/correlation/topology.rs +- Create: crates/cmtraceopen-parser/src/sccm/correlation/link.rs +- Modify: crates/cmtraceopen-parser/src/sccm/correlation/mod.rs +- Modify: crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +- Add shared fixtures matching-topology, missing-topology, incompatible-mp, incompatible-dp, same-content-different-version, and same-minute-unrelated + +**Consumes:** #318 artifact role/host/site/provenance, shared key profile metadata, upstream workflow facts. + +**Produces:** A generic, pair-agnostic compatibility/link mechanism that reports why a potential join is exact, partial, candidate, or incompatible. + +### Compatibility ordering + +Evaluate joins in this deterministic order: + +1. verify client/server roles are eligible for the requested pair; +2. verify source/profile version compatibility; +3. compare exact normalized required keys; +4. compare topology constraints: site, selected server/role, DP host, content version as pair requires; +5. examine capture/coverage/rotation completeness; +6. examine timestamp ordering only if both sides supply valid UTC provenance; +7. classify strength and reason; +8. produce deterministic link/finding/request ordering. + +A later step can lower a strength but may never repair a failed earlier key/topology requirement through time proximity. + +- [ ] **Step 1: Add red compatibility tests** + +Assert exact key plus compatible topology creates a candidate eligible for ExactPartial; exact key plus missing topology stays partial; exact key plus incompatible MP/DP/version is Incompatible; same time/role without key is Candidate at most; stale/unknown profile cannot receive an exact strength; and required coverage gaps lower strength/request counterpart source. + +- [ ] **Step 2: Run red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_contract topology_and_link_strength +~~~ + +- [ ] **Step 3: Implement topology compatibility types and link ranker** + +Use explicit topology outcomes such as Compatible, CompatibleButIncomplete, Unknown, and Incompatible with a bounded reason code. Normalize host/site identity through shared privacy-safe key facilities, not direct lowercase raw string comparisons in every pair module. Link ranker input includes workflow, roles, exact/candidate key matches, topology, coverage, ordering provenance, and source fact terminality. Use BTreeMap/sort by stable key to group/emit links. + +- [ ] **Step 4: Verify and commit generic link mechanics** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_contract +cargo test --locked -p cmtraceopen-parser --test sccm_server_intake +cargo test --locked -p cmtraceopen-parser --test sccm_client_intake +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/correlation crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/shared +git commit -m "feat(sccm): rank topology-aware evidence links" +~~~ + +## Task 3: Implement policy to Management Point correlation + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/correlation/policy_management_point.rs +- Modify: crates/cmtraceopen-parser/src/sccm/correlation/mod.rs +- Create: crates/cmtraceopen-parser/tests/sccm_correlation_policy_management_point.rs +- Create fixtures policy_management_point/healthy, client-request-no-server, server-auth-failure, server-policy-failure, same-time-no-key, assignment-mismatch, topology-mismatch, missing-offset, rotation-split, unknown-profile, contradictory-recovery, and reordered-input +- Modify #321/#328 fixture helpers only if a public fact contract mismatch is demonstrated; do not duplicate their private parsing rules + +**Consumes:** #321 policy transactions/facts and #328 MP transactions/facts, generic link/topology contracts from Task 2. + +**Produces:** Cited policy-MP links, policy-specific cross-side findings, last successful hop, and minimum counterpart artifact requests. + +### Pair state contract + +~~~text +ClientRequest -> MPReceive -> MPAuthenticate -> MPResolvePolicy -> MPRespond -> ClientPersistOrSchedule +~~~ + +The pair result may report the last proven hop only when each adjacent hop is linked by exact/common keys and compatible topology. If source coverage stops between two phases, it must state the gap rather than use the nearest error as the cause. + +- [ ] **Step 1: Write full policy-MP fixture tests before rules** + +Required expected outcomes: + + - healthy flow with an ExactCorroborated link and cited client/server evidence; + - client request failure with no server capture returns client-local finding plus named MP request, not server failure; + - MP auth failure after a proven client request and compatible exact key/topology produces high-confidence cross-side diagnosis only with terminal MP evidence; + - MP policy response failure after successful auth preserves MP last good hop; + - same-time/no-key logs remain a Candidate symptom and never a root cause; + - assignment/request key mismatch, site/MP topology mismatch, missing/invalid offset, unknown profile, and rotation split cannot produce ExactCorroborated; + - a later compatible server/client success shows recovery only for same exact pair key; + - input order does not change serialized links/findings. + +- [ ] **Step 2: Run policy-MP target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_policy_management_point +~~~ + +- [ ] **Step 3: Implement policy-MP fact adapter and pair rules** + +Consume public source facts/transactions rather than matching raw message text. Require the shared profile to say which common key combinations are valid. Build one candidate set per exact policy/request key and topology. Derive pair phases, last successful hop, and findings via shared validation. Link to source findings/evidence instead of copying message/raw values. + +When no matching server fact exists, examine MP coverage. If unavailable, request only relevant MP source group such as server-mp-auth or server-mp-policy. If server records are present but mismatch key/topology, emit an incompatibility reason—do not request generic additional server logs unless a bounded missing source would genuinely resolve it. + +- [ ] **Step 4: Add false-causality and redaction checks** + +Explicitly assert that a nearby MP error from a different assignment/client/site does not modify target client transaction; an unrelated IIS 500-like record cannot establish policy failure; raw context/caller/host markers are absent from exported correlation JSON; and a client-only policy success cannot be downgraded merely because server capture contains unrelated failures. + +- [ ] **Step 5: Verify, commit, and issue handoff** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_policy_management_point +cargo test --locked -p cmtraceopen-parser --test sccm_client_policy +cargo test --locked -p cmtraceopen-parser --test sccm_server_management_point +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/correlation crates/cmtraceopen-parser/tests/sccm_correlation_policy_management_point.rs crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/policy_management_point +git commit -m "feat(sccm): correlate policy and management point evidence" +~~~ + +Update #333 and link #321/#328 with fixture/profile/key scope and an explicit no-time-only statement. + +## Task 4: Implement content to Distribution Point correlation + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/correlation/content_distribution_point.rs +- Modify: crates/cmtraceopen-parser/src/sccm/correlation/mod.rs +- Create: crates/cmtraceopen-parser/tests/sccm_correlation_content_distribution_point.rs +- Create fixtures content_distribution_point/healthy, client-location-no-dp, client-transfer-failure, dp-distribution-failure, dp-validation-failure, content-version-mismatch, dp-topology-mismatch, same-time-no-key, missing-offset, rotation-split, unknown-profile, contradictory-recovery, and reordered-input + +**Consumes:** #322 deployment/content facts and #329 DP content facts, generic link/topology rules. + +**Produces:** Cited content-DP links, conservative last-hop outputs, and no DP root-cause claim unless a compatible exact pair supports it. + +### Pair state contract + +~~~text +ClientLocateContent -> DPContentAvailable -> ClientTransferStart -> DPServeOrObserve -> ClientCache -> ClientEnforce +~~~ + +This is a correlation view, not a replacement for either source state machine. The DP may not observe a precise client transfer/serve event in all deployments; absence of that server observation lowers confidence/creates a request. It must not force an impossible full end-to-end link. + +- [ ] **Step 1: Write content-DP fixture tests first** + +Test: + + - healthy compatible content/version/DP evidence produces ExactCorroborated or an explicitly defined ExactPartial if expected server confirmation is not available; + - client location failure with absent DP evidence requests the named DP artifact and does not call it DP failure; + - client transfer/cache failure remains client-local when DP availability is proven; + - terminal DP distribution/validation failure plus compatible client content request yields a high-confidence server-side block only if coverage/keys/topology/order satisfy rules; + - same content ID but different version or DP gives Incompatible; + - same-minute generic transfer/DP errors stay Candidate; + - missing offset, rotation split, unknown profile, conflicting recovery, and reordered input never produce a false high-confidence result. + +- [ ] **Step 2: Run target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_content_distribution_point +~~~ + +- [ ] **Step 3: Implement content/DP fact adapter and pair rules** + +Use only exact normalized content/package/version/DP keys admitted by shared profiles. Make content version requirement explicit by profile; never assume an unversioned ID is sufficient. Validate DP topology against client selected/located DP when available. Model server role availability/validation facts separately from client transfer/cache/enforce. Generate a cross-side finding only when an exact pair establishes a meaningful boundary; otherwise preserve source-local outcomes and add bounded counterpart request. + +- [ ] **Step 4: Add adversarial multi-content/multi-DP tests** + +Add fixture cases with two deployments using the same content name but different IDs, same content ID across versions, two DPs, two client transactions in the same minute, and an unrelated server failure. Assert no shared transaction/link/finding exists beyond exact compatible pairs. + +- [ ] **Step 5: Verify, commit, and record pair limits** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_content_distribution_point +cargo test --locked -p cmtraceopen-parser --test sccm_client_deployment +cargo test --locked -p cmtraceopen-parser --test sccm_server_distribution_point +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/correlation crates/cmtraceopen-parser/tests/sccm_correlation_content_distribution_point.rs crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/content_distribution_point +git commit -m "feat(sccm): correlate content and distribution point evidence" +~~~ + +Update #333 with exact supported content/version/topology profile and leave client transfer/cache versus server-availability limits explicit. + +## Task 5: Establish a controlled extension gate for later correlation pairs + +**Files:** + +- Modify: crates/cmtraceopen-parser/src/sccm/correlation/rules.rs +- Modify: crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +- Modify: GitHub issue #333 with a subtask checklist or link individual future pair issues +- Do not create updates/SUP pair code in this task + +**Consumes:** Shipping policy-MP/content-DP pair contract and #323/#330 only as future upstream contracts. + +**Produces:** A repeatable pair-admission checklist that blocks accidental generic correlation expansion. + +- [ ] **Step 1: Write a pair registry test** + +Add a private/typed pair registry declaring supported pairs. Test that an unregistered workflow combination returns a no-link/coverage result and cannot invoke a generic all-matching-keys join. Test that every registered pair declares required client/server role, exact key types, topology constraints, source coverage requirements, ordering policy, terminal proof condition, and fixture directory. + +- [ ] **Step 2: Run red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_contract pair_registry +~~~ + +- [ ] **Step 3: Implement pair registry and extension checklist** + +Keep policy-MP and content-DP as the only RuleValidated pairs. Add an explicit Candidate entry for updates-SUP only if #323/#330 have defined compatible upstream facts; Candidate cannot run correlation. The checklist for promotion requires a planned pair module, success/failure/incomplete/adversarial fixtures, version/key profile, topology rules, privacy review, and independently passing source analyzers. + +- [ ] **Step 4: Verify and commit** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_contract +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_policy_management_point +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_content_distribution_point +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/correlation crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +git commit -m "feat(sccm): gate correlation pair expansion" +~~~ + +## Task 6: Run #333 release and review gates + +**Files:** + +- Modify: crates/cmtraceopen-parser/README.md only if public API documentation is needed after implementation +- Modify: GitHub #333 with pair-specific evidence, tests, fixtures, and known limits +- Modify CI only when existing focused tests are stable; correlation itself requires no live service + +**Consumes:** All correlation tasks plus independently green upstream source analyzer suites. + +**Produces:** A reviewable correlation release that is explicit about what is proven, what is merely linked, and what remains unlinked. + +- [ ] **Step 1: Run every focused source/correlation suite** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_contract +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_policy_management_point +cargo test --locked -p cmtraceopen-parser --test sccm_correlation_content_distribution_point +cargo test --locked -p cmtraceopen-parser --test sccm_client_policy +cargo test --locked -p cmtraceopen-parser --test sccm_server_management_point +cargo test --locked -p cmtraceopen-parser --test sccm_client_deployment +cargo test --locked -p cmtraceopen-parser --test sccm_server_distribution_point +cargo test --locked -p cmtraceopen-parser +~~~ + +- [ ] **Step 2: Run compatibility and static analysis gates** + +~~~bash +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo fmt --check --all +git diff --check +~~~ + +- [ ] **Step 3: Perform a dedicated false-causality review** + +Review adversarial fixtures before approving #333: same time/no key; exact key/different topology; exact content/different version; missing/invalid offset; unknown profile; rotation split; partial capture; unrelated terminal server error; client-only; server-only; and reordering. A reviewer must be able to point to a test that prevents each unsafe high-confidence conclusion. + +- [ ] **Step 4: Inspect public JSON and redaction projection** + +Serialize a healthy pair, terminal pair, incomplete pair, and incompatible pair. Check schema version, deterministic IDs/order, cited evidence, confidence ceiling, last good hop, missing counterpart request, no raw user/context/path/host secrets, and source findings unchanged by correlation. Verify redacted projection does not mutate internal result. + +- [ ] **Step 5: Report issue closure evidence by pair** + +For policy-MP, report exact keys/profile/topology/fixtures and coverage limits. For content-DP, report content/version/DP topology scope and client-transfer versus server-availability limits. List updates-SUP only as a gated future candidate if applicable. Keep #333 open if either first pair lacks a required adversarial fixture or a source contract has not stabilized. + +## Exit Criteria + +- [ ] Cross-side code uses only registered RuleValidated pairs. +- [ ] Policy-MP and content-DP outcomes have healthy, terminal, incomplete, incompatible, unknown-profile, invalid-offset, rotation, and reordering fixtures. +- [ ] Candidate/time-only/incompatible/unlinked evidence cannot generate high-confidence causal findings. +- [ ] Client-only/server-only results remain useful and request minimal counterpart evidence. +- [ ] Public JSON is deterministic, cited, redacted, and additive; source analysis remains intact. +- [ ] Future pair expansion is blocked until a dedicated source/key/topology/fixture review passes. diff --git a/docs/superpowers/plans/2026-07-30-sccm-diagnostic-spine.md b/docs/superpowers/plans/2026-07-30-sccm-diagnostic-spine.md new file mode 100644 index 000000000..ba034ba37 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-sccm-diagnostic-spine.md @@ -0,0 +1,833 @@ +# SCCM Shared Diagnostic Spine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement issue #318: a pure, serializable SCCM diagnostic contract that turns classified raw records into evidence, signals, stable keys, transactions, coverage, and conservative findings. + +**Architecture:** Add a new parser-owned sccm module without changing public CCM parsing behavior or ParserKind. Factor CCM logical framing behind an internal enriched record envelope so SCCM ingest can retain context, physical line range, timestamp-parse validity, and source-code file metadata while ordinary callers continue to receive unchanged LogEntry values. SCCM models own serialization and privacy semantics; catalog/classification, signal extraction, key normalization, and finding construction stay in focused files with no I/O. + +**Tech Stack:** Rust 1.88, serde, serde_json, chrono, regex, cmtraceopen-parser, standard Rust tests. + +## Global Constraints + +- This plan implements #318 only. It creates no client/server source discovery, no Tauri command, no workspace UI, and no workflow-specific SCCM rules. +- The API consumes content/provenance supplied by callers. It cannot open files, enumerate folders, read registry, run commands, query WMI, or call a network service. +- Reuse parser::ccm for framing and timestamp parsing. No SCCM-specific ParserKind or duplicate record parser. +- Preserve a raw artifact identity separately from LogEntry.source_file, because source_file is the component source-code attribute while the artifact identity names the captured log. +- Existing LogEntry serialization must not change. Factor an internal CCM logical-record envelope and let SCCM ingest consume it; do not add a SCCM-only context field to public LogEntry or require downstream callers to update struct literals. SCCM evidence may carry a privacy-classified/redacted context handle only after the envelope/redaction tests pass. +- Signal extraction is diagnostic metadata, not error_db UI highlighting. Preserve unknown tokens, numeric form, original text, and span even when error_db has no description. +- New models use serde camelCase, derive Debug/Clone/PartialEq, and use Unknown(String) for externally supplied enum values that can evolve. +- Use UTC epoch milliseconds only for ordering. Retain original timestamp display and offset in evidence. +- Never output raw user names, credential-like text, client tokens, or user context in public evidence. Preserve a deterministic redacted handle only when a downstream correlation need is explicit and reviewed. + +--- + +## File Structure + +- Create: crates/cmtraceopen-parser/src/sccm/mod.rs — public SCCM façade and focused re-exports. +- Create: crates/cmtraceopen-parser/src/sccm/models.rs — schema version, enums, artifact/evidence/transaction/finding models. +- Create: crates/cmtraceopen-parser/src/sccm/catalog.rs — filename-to-role/workload catalog and artifact classification. +- Create: crates/cmtraceopen-parser/src/sccm/signals.rs — known/unknown HRESULT, Win32, GLE, status, exit-code, and return-code extraction. +- Create: crates/cmtraceopen-parser/src/sccm/keys.rs — stable-key normalization and version-aware extractor metadata. +- Create: crates/cmtraceopen-parser/src/sccm/ingest.rs — artifact-content to SCCM evidence normalization using the internal CCM logical-record envelope. +- Create: crates/cmtraceopen-parser/src/sccm/evidence.rs — evidence IDs, timestamp/provenance projection, context redaction, and public export boundary. +- Create: crates/cmtraceopen-parser/src/sccm/findings.rs — conservative finding builder/validation and next-artifact request models. +- Create: crates/cmtraceopen-parser/tests/sccm_spine_contract.rs — public JSON/schema, catalog, signal, key, privacy, framing, and finding contracts. +- Create: crates/cmtraceopen-parser/tests/fixtures/sccm/spine/multiline-policy.log — sanitized logical CCM record split across physical lines. +- Create: crates/cmtraceopen-parser/tests/fixtures/sccm/spine/artifact-manifest.json — sanitized artifact provenance/coverage scenario. +- Modify: crates/cmtraceopen-parser/src/lib.rs — publish the new sccm module. +- Modify: crates/cmtraceopen-parser/src/parser/ccm.rs — factor the existing logical-record scanner into a crate-private envelope without changing the public parse_content or LogEntry contract. + +## Public Interfaces + +All types below are new public parser-crate types. Do not relocate ESP types or make SCCM depend on esp. + +~~~rust +pub const SCCM_DIAGNOSTICS_SCHEMA_VERSION: u32 = 1; + +pub enum SccmCoverageState { + Captured, + Absent, + AccessDenied, + Capped, + Skipped, + Unsupported, + ParseFailed, +} + +pub enum SccmRole { + Client, + SiteServer, + ManagementPoint, + DistributionPoint, + SoftwareUpdatePoint, + WsUs, + Provider, + Unknown(String), +} + +pub enum SccmFindingClass { + Symptom, + ConfirmedFailure, + BlockedOrDeferred, + LikelyContributor, + InsufficientEvidence, +} + +pub enum SccmConfidence { + None, + Low, + Moderate, + High, +} + +pub struct SccmArtifact { + pub artifact_id: String, + pub display_name: String, + pub original_path: Option, + pub host: Option, + pub role: SccmRole, + pub configmgr_version: Option, + pub collected_at_utc: Option, + pub rotation: SccmRotation, + pub coverage: SccmCoverageState, + pub encoding: Option, +} + +pub struct SccmEvidenceRef { + pub artifact_id: String, + pub entry_id: String, + pub line_start: Option, + pub line_end: Option, +} + +pub struct SccmEvidence { + pub evidence_id: String, + pub reference: SccmEvidenceRef, + pub role: SccmRole, + pub component: Option, + pub ccm_source_file: Option, + pub message: String, + pub timestamp: SccmTimestamp, + pub signals: Vec, + pub keys: Vec, + pub execution_context: Option, +} + +pub struct SccmFinding { + pub finding_id: String, + pub class: SccmFindingClass, + pub phase: SccmPhase, + pub role: SccmRole, + pub severity: Severity, + pub confidence: SccmConfidence, + pub title: String, + pub summary: String, + pub evidence: Vec, + pub coverage_gap_artifact_ids: Vec, + pub correlation_keys: Vec, + pub next_artifacts: Vec, +} +~~~ + +### Task 1: Create the empty public SCCM module and compile-only API boundary + +**Files:** +- Create: crates/cmtraceopen-parser/src/sccm/mod.rs +- Create: crates/cmtraceopen-parser/src/sccm/models.rs +- Modify: crates/cmtraceopen-parser/src/lib.rs +- Create: crates/cmtraceopen-parser/tests/sccm_spine_contract.rs + +**Consumes:** Existing crate root conventions, serde, models::log_entry::Severity. + +**Produces:** A compilable sccm module with schema version and the smallest stable model set. + +- [ ] **Step 1: Write the failing public-import test** + +Create the test file with a compile-use contract: + +~~~rust +use cmtraceopen_parser::sccm::{ + SccmArtifact, SccmCoverageState, SccmFindingClass, SccmRole, + SCCM_DIAGNOSTICS_SCHEMA_VERSION, +}; + +#[test] +fn sccm_contract_is_public_and_versioned() { + assert_eq!(SCCM_DIAGNOSTICS_SCHEMA_VERSION, 1); + let artifact = SccmArtifact::missing( + "client-policy-agent", + "PolicyAgent.log", + SccmRole::Client, + SccmCoverageState::Absent, + ); + assert_eq!(artifact.coverage, SccmCoverageState::Absent); + assert_eq!(SccmFindingClass::InsufficientEvidence.as_str(), "insufficientEvidence"); +} +~~~ + +- [ ] **Step 2: Run the focused test before implementation** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract sccm_contract_is_public_and_versioned -- --exact +~~~ + +Expected: FAIL because the sccm module and its public types do not exist. + +- [ ] **Step 3: Add the module declaration and exact minimum types** + +Add to the crate root: + +~~~rust +pub mod sccm; +~~~ + +Create sccm/mod.rs with: + +~~~rust +pub mod models; + +pub use models::*; +~~~ + +In sccm/models.rs, derive Serialize and Deserialize for public types, apply serde rename_all = "camelCase", and define the missing constructor: + +~~~rust +impl SccmArtifact { + pub fn missing( + artifact_id: impl Into, + display_name: impl Into, + role: SccmRole, + coverage: SccmCoverageState, + ) -> Self { + Self { + artifact_id: artifact_id.into(), + display_name: display_name.into(), + original_path: None, + host: None, + role, + configmgr_version: None, + collected_at_utc: None, + rotation: SccmRotation::Current, + coverage, + encoding: None, + } + } +} +~~~ + +- [ ] **Step 4: Run format, focused test, and public API compile test** + +Run: + +~~~bash +cargo fmt --check --all +cargo test -p cmtraceopen-parser --test sccm_spine_contract sccm_contract_is_public_and_versioned -- --exact +~~~ + +Expected: PASS. + +- [ ] **Step 5: Commit the empty-but-usable contract boundary** + +~~~bash +git add crates/cmtraceopen-parser/src/lib.rs crates/cmtraceopen-parser/src/sccm crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +git commit -m "feat(sccm): add diagnostic contract boundary" +~~~ + +### Task 2: Define complete artifact provenance and coverage semantics + +**Files:** +- Modify: crates/cmtraceopen-parser/src/sccm/models.rs +- Modify: crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +- Create: crates/cmtraceopen-parser/tests/fixtures/sccm/spine/artifact-manifest.json + +**Consumes:** SccmArtifact from Task 1. + +**Produces:** Round-trippable artifact provenance with explicit coverage and rotation semantics. + +- [ ] **Step 1: Add failing JSON round-trip and coverage tests** + +~~~rust +#[test] +fn artifact_round_trip_preserves_capture_and_rotation_provenance() { + let artifact = SccmArtifact { + artifact_id: "client-content-transfer".into(), + display_name: "ContentTransferManager.log.2".into(), + original_path: Some(r"C:\Windows\CCM\Logs\ContentTransferManager.log.2".into()), + host: Some("LAB-CLIENT-01".into()), + role: SccmRole::Client, + configmgr_version: Some("5.00.9128.1007".into()), + collected_at_utc: Some("2026-07-30T15:00:00Z".into()), + rotation: SccmRotation::Numbered(2), + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".into()), + }; + + let json = serde_json::to_value(&artifact).unwrap(); + assert_eq!(json["rotation"]["kind"], "numbered"); + assert_eq!(json["coverage"], "captured"); + assert_eq!(serde_json::from_value::(json).unwrap(), artifact); +} + +#[test] +fn coverage_states_are_distinct_and_never_deserialize_as_captured() { + for state in [ + SccmCoverageState::Absent, + SccmCoverageState::AccessDenied, + SccmCoverageState::Capped, + SccmCoverageState::Skipped, + SccmCoverageState::Unsupported, + SccmCoverageState::ParseFailed, + ] { + assert_ne!(state, SccmCoverageState::Captured); + } +} +~~~ + +- [ ] **Step 2: Run the coverage tests and confirm red** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract artifact_round_trip_preserves_capture_and_rotation_provenance -- --exact +~~~ + +Expected: FAIL until SccmRotation has a stable tagged representation and all coverage states exist. + +- [ ] **Step 3: Implement exact enum behavior** + +Use a tagged rotation representation so a JSON consumer can distinguish current, CMTrace .lo_, dated history, and numeric history: + +~~~rust +pub enum SccmRotation { + Current, + LoUnderscore, + Numbered(u32), + Timestamped(String), + Unknown(String), +} +~~~ + +Serialize it as a tagged object with kind and value fields. Ensure coverage state names are the lower camelCase values listed in the epic. Do not collapse AccessDenied, Capped, Skipped, or ParseFailed into Absent. + +- [ ] **Step 4: Add a fixture-backed manifest test** + +Create artifact-manifest.json with one captured current artifact, one numbered rotation, one absent log, and one access-denied registry export. Deserialize it in a test and assert every artifact retains its own state. + +- [ ] **Step 5: Verify and commit** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract artifact_ +cargo fmt --check --all +git diff --check +~~~ + +Then commit: + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/models.rs crates/cmtraceopen-parser/tests +git commit -m "feat(sccm): model artifact coverage and rotation" +~~~ + +### Task 3: Classify artifacts by filename and role without parsing a record + +**Files:** +- Create: crates/cmtraceopen-parser/src/sccm/catalog.rs +- Modify: crates/cmtraceopen-parser/src/sccm/mod.rs +- Modify: crates/cmtraceopen-parser/tests/sccm_spine_contract.rs + +**Consumes:** SccmArtifact, SccmRole, normalized display name. + +**Produces:** Deterministic SccmSourceCatalogEntry and classify_artifact_name. + +- [ ] **Step 1: Write failing catalog tests for raw grammar reuse** + +~~~rust +#[test] +fn catalog_classifies_client_policy_without_changing_ccm_parser_kind() { + let class = classify_artifact_name("PolicyAgent.log", SccmRole::Client); + assert_eq!(class.family, SccmArtifactFamily::ClientPolicy); + assert_eq!(class.logical_name, "policyAgent"); + assert!(class.uses_ccm_records); +} + +#[test] +fn catalog_recognizes_rotated_client_log_by_base_name() { + let class = classify_artifact_name("AppEnforce.log.3", SccmRole::Client); + assert_eq!(class.family, SccmArtifactFamily::ClientApplication); + assert_eq!(class.rotation, SccmRotation::Numbered(3)); +} + +#[test] +fn catalog_leaves_unrecognized_sources_explicitly_unknown() { + let class = classify_artifact_name("CustomVendorHook.log", SccmRole::Client); + assert_eq!(class.family, SccmArtifactFamily::Unknown("customVendorHook".into())); + assert!(!class.supported_for_diagnosis); +} +~~~ + +- [ ] **Step 2: Run the catalog tests before implementation** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract catalog_ -- --nocapture +~~~ + +Expected: FAIL because classifier symbols do not exist. + +- [ ] **Step 3: Implement a small immutable catalog** + +Define SourceCatalogEntry values for only the shared initial names: CCMSetup, CcmEval, CcmExec, CcmRestart, ClientIDManagerStartup, ClientLocation, LocationServices, CcmMessaging, PolicyAgent, PolicyAgentProvider, PolicyEvaluator, Scheduler, CAS, ContentTransferManager, DataTransferService, AppIntentEval, AppDiscovery, AppEnforce, ScanAgent, WUAHandler, UpdatesDeployment, UpdatesHandler, UpdatesStore, smsts, sitecomp, hman, statmgr, statesys, MP_CliReg, MP_GetAuth, MP_GetPolicy, MP_Location, MP_RegistrationManager, mpcontrol, distmgr, PkgXferMgr, SMSDPProv, PullDP, WCM, WSUSCtrl, wsyncmgr, SUPSetup, replmgr, rcmctrl, sender, despool, Smsprov, and AdminService. + +The catalog must return unsupported or unknown for every entry outside its declared list. It must never infer a workflow from a message alone. + +- [ ] **Step 4: Verify catalog behavior** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract catalog_ +cargo clippy -p cmtraceopen-parser --all-targets -- -D warnings +~~~ + +Expected: PASS. + +- [ ] **Step 5: Commit** + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/catalog.rs crates/cmtraceopen-parser/src/sccm/mod.rs crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +git commit -m "feat(sccm): classify diagnostic artifact families" +~~~ + +### Task 4: Preserve logical-record evidence and timestamp provenance + +**Files:** +- Modify: crates/cmtraceopen-parser/src/parser/ccm.rs +- Create: crates/cmtraceopen-parser/src/sccm/ingest.rs +- Create: crates/cmtraceopen-parser/src/sccm/evidence.rs +- Modify: crates/cmtraceopen-parser/src/sccm/mod.rs +- Modify: crates/cmtraceopen-parser/src/sccm/models.rs +- Create: crates/cmtraceopen-parser/tests/fixtures/sccm/spine/multiline-policy.log +- Modify: crates/cmtraceopen-parser/tests/sccm_spine_contract.rs + +**Consumes:** parser::ccm internal logical-record framing, unchanged public parse_content/LogEntry behavior, SccmArtifact, and catalog classification. + +**Produces:** normalize_ccm_artifact(artifact, content) and deterministic SCCM evidence references with complete logical line ranges and safe provenance. + +- [ ] **Step 1: Add a multiline-framing regression test** + +Use a fixture containing a PolicyAgent message split across physical lines: + +~~~text + +~~~ + +Test the raw parser and evidence conversion: + +~~~rust +#[test] +fn evidence_uses_one_logical_record_and_normalized_utc_ordering() { + let text = include_str!("fixtures/sccm/spine/multiline-policy.log"); + let (entries, errors) = cmtraceopen_parser::parser::ccm::parse_content(text, "PolicyAgent.log", None); + assert_eq!(errors, 0); + assert_eq!(entries.len(), 1, "ordinary public CCM output stays unchanged"); + + let evidence = normalize_ccm_artifact(client_policy_artifact(), text); + assert_eq!(evidence.len(), 1); + assert_eq!(evidence[0].reference.line_start, Some(1)); + assert_eq!(evidence[0].reference.line_end, Some(2)); + assert_eq!(evidence[0].ccm_source_file.as_deref(), Some("policyagent.cpp")); + assert_eq!(evidence[0].timestamp.original_display.as_deref(), Some("07-30-2026 10:00:00.000")); + assert_eq!(evidence[0].timestamp.offset_minutes, Some(-240)); + assert!(evidence[0].timestamp.utc_millis.is_some()); +} +~~~ + +- [ ] **Step 2: Run the framing test before implementation** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract evidence_uses_one_logical_record_and_normalized_utc_ordering -- --exact +~~~ + +Expected: FAIL because the evidence conversion API does not exist. + +- [ ] **Step 3: Factor CCM framing into an internal rich envelope before SCCM ingest** + +Introduce a crate-private envelope in parser/ccm.rs that holds the unchanged LogEntry projection plus raw metadata required by SCCM: + +~~~rust +pub(crate) struct CcmLogicalRecord { + pub entry: LogEntry, + pub context: Option, + pub line_start: u32, + pub line_end: u32, + pub timestamp: CcmTimestampParse, +} +~~~ + +Move the existing whole-content logical scanner into a shared private function that returns CcmLogicalRecord values. Public parse_content and parse_lines_with_specialization must project only record.entry exactly as they do today. SCCM ingest may call the crate-private shared scanner, never reproduce the CCM regex or physical-line loop. + +Before adding SCCM ingest, add regression tests proving that existing public CCM entries and parse-error counts are byte-for-byte/equivalence unchanged for: a single record; the multiline fixture; malformed continuation; no timestamp offset; and existing CCM unit fixtures. Run the focused public parser tests to green, then commit the internal refactor separately: + +~~~bash +cargo test --locked -p cmtraceopen-parser parser::ccm +git add crates/cmtraceopen-parser/src/parser/ccm.rs crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +git commit -m "refactor(ccm): retain internal logical record metadata" +~~~ + +- [ ] **Step 4: Implement SccmTimestamp, provenance, and evidence construction** + +Define: + +~~~rust +pub struct SccmTimestamp { + pub original_display: Option, + pub offset_minutes: Option, + pub utc_millis: Option, + pub ordering_state: SccmTimeOrderingState, +} + +pub enum SccmTimeOrderingState { + NormalizedUtc, + OffsetMissing, + OffsetInvalid, + TimestampMissing, +} +~~~ + +Use the rich envelope's parsed timestamp state together with the existing LogEntry timestamp, timestamp_display, and timezone_offset projection. Do not call chrono::Local or infer a missing client/server offset. A missing or invalid offset leaves utc_millis unset for cross-host ordering and sets the correct state. Keep the artifact basename/original-path handle separate from ccm_source_file, and use line_start/line_end from the envelope rather than inventing line numbers after parsing. + +- [ ] **Step 5: Handle context and privacy compatibility explicitly** + +Add tests proving the public LogEntry API still does not expose a new context field, while the SCCM path can receive the envelope context. First test the redacted export projection: a fixture context such as NT AUTHORITY\\SYSTEM or LAB\\SyntheticUser must not appear raw in public SCCM JSON; only an approved deterministic sensitive handle may appear when a reviewed correlation rule needs it. Test that the raw internal snapshot is not mutated by export redaction. + +Do not add context to LogEntry, change its serde shape, or update external struct literals. If a future public raw-parser context API is genuinely needed, open a separate compatibility issue with a public-versioning review; it is explicitly out of #318. + +- [ ] **Step 6: Verify SCCM ingest and commit** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract evidence_ +cargo test -p cmtraceopen-parser +cargo fmt --check --all +~~~ + +Commit: + +~~~bash +git add crates/cmtraceopen-parser/src/sccm crates/cmtraceopen-parser/tests +git commit -m "feat(sccm): normalize framed evidence provenance" +~~~ + +### Task 5: Extract diagnostic signals without losing unknown codes + +**Files:** +- Create: crates/cmtraceopen-parser/src/sccm/signals.rs +- Modify: crates/cmtraceopen-parser/src/sccm/mod.rs +- Modify: crates/cmtraceopen-parser/tests/sccm_spine_contract.rs + +**Consumes:** Reassembled SccmEvidence.message and existing error_db lookup result only as optional enrichment. + +**Produces:** extract_signals(message) -> Vec. + +- [ ] **Step 1: Add failing known and unknown signal tests** + +~~~rust +#[test] +fn signal_extractor_preserves_known_hresult_and_error_db_metadata() { + let signals = extract_signals("Download failed with hr=0x80070005"); + assert_eq!(signals.len(), 1); + assert_eq!(signals[0].kind, SccmSignalKind::HResult); + assert_eq!(signals[0].raw, "0x80070005"); + assert_eq!(signals[0].numeric, Some(0x80070005)); + assert!(signals[0].error_description.is_some()); +} + +#[test] +fn signal_extractor_preserves_unknown_exit_and_gle_values() { + let signals = extract_signals("exit code 1603; [gle=0xDEADBEEF]; status=71"); + assert_eq!( + signals.iter().map(|signal| (&signal.kind, signal.raw.as_str())).collect::>(), + vec![ + (&SccmSignalKind::ExitCode, "1603"), + (&SccmSignalKind::Gle, "0xDEADBEEF"), + (&SccmSignalKind::Status, "71"), + ] + ); + assert!(signals.iter().all(|signal| signal.error_description.is_none() || !signal.raw.is_empty())); +} +~~~ + +- [ ] **Step 2: Run the signal tests and confirm red** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract signal_extractor_ -- --nocapture +~~~ + +Expected: FAIL because signal extractor types and function do not exist. + +- [ ] **Step 3: Implement focused regexes with deterministic precedence** + +Extract, in message order, only exact structured forms: + +~~~text +hr=0xNNNNNNNN +HRESULT 0xNNNNNNNN +[gle=0xNNNNNNNN] +exit code N +exitCode = N +return code N +status=N +~~~ + +Record UTF-8 byte-independent span positions using character offsets or clear source indexes. Do not consume GUIDs as codes. Deduplicate only identical kind/raw/span triples; preserve repeated tokens at different positions. + +- [ ] **Step 4: Enrich known values but retain unknown values** + +Use error_db only after a token is captured. If lookup resolves, add description/category to the signal. If not, keep numeric/raw data and leave enrichment None. No signal extractor may discard an unknown code. + +- [ ] **Step 5: Verify and commit** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract signal_ +cargo clippy -p cmtraceopen-parser --all-targets -- -D warnings +git diff --check +~~~ + +Commit: + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/signals.rs crates/cmtraceopen-parser/src/sccm/mod.rs crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +git commit -m "feat(sccm): retain diagnostic signal tokens" +~~~ + +### Task 6: Normalize version-aware correlation keys conservatively + +**Files:** +- Create: crates/cmtraceopen-parser/src/sccm/keys.rs +- Modify: crates/cmtraceopen-parser/src/sccm/models.rs +- Modify: crates/cmtraceopen-parser/src/sccm/mod.rs +- Modify: crates/cmtraceopen-parser/tests/sccm_spine_contract.rs + +**Consumes:** SccmEvidence, SccmArtifact.configmgr_version, signal/source metadata. + +**Produces:** extract_keys(evidence, extraction_profile) and normalized key evidence. + +- [ ] **Step 1: Add failing key-normalization tests** + +~~~rust +#[test] +fn key_normalization_is_stable_across_case_and_brace_variants() { + let left = normalize_key(SccmCorrelationKeyKind::AssignmentId, "{ABCDEFAB-0000-0000-0000-000000000001}"); + let right = normalize_key(SccmCorrelationKeyKind::AssignmentId, "abcdefab-0000-0000-0000-000000000001"); + assert_eq!(left.normalized, right.normalized); + assert_eq!(left.confidence, SccmKeyConfidence::Exact); +} + +#[test] +fn unvalidated_version_cannot_emit_exact_extracted_key() { + let result = extract_keys( + &evidence_with_message("Policy id={ABCDEFAB-0000-0000-0000-000000000001}"), + &SccmExtractionProfile::for_version(Some("unobserved-version")), + ); + assert!(result.keys.is_empty()); + assert_eq!(result.gaps[0].kind, SccmExtractionGapKind::UnvalidatedVersion); +} +~~~ + +- [ ] **Step 2: Run and confirm red** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract key_ -- --nocapture +~~~ + +Expected: FAIL because the key contract does not exist. + +- [ ] **Step 3: Implement key kinds, confidence, and versioned profiles** + +Start with normalized lexical rules for assignment ID, client GUID, package ID, content ID, site code, server host, CI ID, update/KB, BITS job ID, task-sequence execution ID, request/topic ID, and state message ID. Profile selection must declare: + +~~~rust +pub struct SccmExtractionProfile { + pub profile_id: String, + pub configmgr_version_prefixes: Vec, + pub validated_artifact_families: Vec, +} +~~~ + +Unknown version has no validated profile by default. It may still preserve candidate raw text inside a gap record but must not emit an Exact or Strong key. + +- [ ] **Step 4: Add two-version fixture gates** + +For every profile promoted to stable, add fixture cases with at least two observed version labels or keep the profile experimental with low-confidence-only output. Test version-prefix selection and normalized equality. + +- [ ] **Step 5: Verify and commit** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract key_ +cargo test -p cmtraceopen-parser +cargo fmt --check --all +~~~ + +Commit: + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/keys.rs crates/cmtraceopen-parser/src/sccm/models.rs crates/cmtraceopen-parser/tests +git commit -m "feat(sccm): add versioned correlation keys" +~~~ + +### Task 7: Enforce conservative finding construction + +**Files:** +- Create: crates/cmtraceopen-parser/src/sccm/findings.rs +- Modify: crates/cmtraceopen-parser/src/sccm/mod.rs +- Modify: crates/cmtraceopen-parser/tests/sccm_spine_contract.rs + +**Consumes:** SccmEvidenceRef, SccmCorrelationKey, SccmCoverageState, Severity. + +**Produces:** SccmFindingBuilder::build and validation errors for unsound findings. + +- [ ] **Step 1: Add failing finding-safety tests** + +~~~rust +#[test] +fn confirmed_failure_requires_terminal_evidence() { + let result = SccmFindingBuilder::new("app-enforcement-failed") + .class(SccmFindingClass::ConfirmedFailure) + .phase(SccmPhase::Enforcement) + .role(SccmRole::Client) + .severity(Severity::Error) + .confidence(SccmConfidence::High) + .evidence(vec![single_nonterminal_error_ref()]) + .build(); + + assert_eq!(result.unwrap_err(), SccmFindingValidationError::MissingTerminalEvidence); +} + +#[test] +fn insufficient_evidence_requires_next_artifact_request() { + let result = SccmFindingBuilder::new("missing-policy-log") + .class(SccmFindingClass::InsufficientEvidence) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .coverage_gap("client-policy-agent") + .build(); + + assert_eq!(result.unwrap_err(), SccmFindingValidationError::MissingNextArtifactRequest); +} +~~~ + +- [ ] **Step 2: Run and confirm red** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract confirmed_failure_requires_terminal_evidence -- --exact +~~~ + +Expected: FAIL because SccmFindingBuilder does not exist. + +- [ ] **Step 3: Implement the validation rules** + +Require: + +- ConfirmedFailure with High confidence: at least one evidence reference marked terminal or two corroborating references with the same exact/strong key. +- LikelyContributor: confidence no higher than Moderate unless corroborated by a terminal transaction record. +- InsufficientEvidence: one or more coverage gaps plus one or more next artifact requests. +- Any finding with no evidence and no coverage gap: reject. +- Any request for an artifact: use catalog logical name, role, and reason; never ask for an unbounded entire drive. + +- [ ] **Step 4: Add JSON and ordering contracts** + +Serialize a valid blocked/deferred finding, deserialize it, and assert evidence/correlation-key arrays preserve deterministic sorted order. Add a test that same-minute but keyless evidence cannot construct a High confidence finding. + +- [ ] **Step 5: Verify and commit** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser --test sccm_spine_contract finding_ +cargo clippy -p cmtraceopen-parser --all-targets -- -D warnings +git diff --check +~~~ + +Commit: + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/findings.rs crates/cmtraceopen-parser/src/sccm/mod.rs crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +git commit -m "feat(sccm): enforce evidence-backed findings" +~~~ + +### Task 8: Run the shared-contract regression suite and document the exact boundary + +**Files:** +- Modify: crates/cmtraceopen-parser/README.md +- Modify: docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md only if code names diverge from the approved design +- Modify: GitHub issue #318 with verification evidence after local tests pass + +**Consumes:** All Task 1 through Task 7 APIs and tests. + +**Produces:** A reviewable contract release gate with documented non-goals. + +- [ ] **Step 1: Add a concise README SCCM contract section** + +Document that SCCM diagnostics classify and correlate supplied artifacts over CCM records, retain unknown signals, represent coverage gaps, and do not perform on-device collection in the parser crate. + +- [ ] **Step 2: Run all parser-only tests** + +Run: + +~~~bash +cargo test -p cmtraceopen-parser +cargo clippy -p cmtraceopen-parser --all-targets -- -D warnings +cargo fmt --check --all +git diff --check +~~~ + +Expected: PASS. + +- [ ] **Step 3: Inspect public JSON manually** + +Run an existing test with --nocapture or add a temporary non-committed debug serialization in the test. Check camelCase fields, no raw execution context, and expected coverage-state names. Remove all debug output before commit. + +- [ ] **Step 4: Commit documentation separately** + +~~~bash +git add crates/cmtraceopen-parser/README.md docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md +git commit -m "docs(sccm): describe diagnostic contract boundary" +~~~ + +- [ ] **Step 5: Update issue #318 with completion evidence** + +Post the exact test commands, commit IDs, fixture names, versioned profiles supported, and any explicitly deferred raw-context compatibility work. Do not close #318 until reviewers approve the contract and a native-independent CI run is green. + +## Final #318 Review Checklist + +- [ ] No new platform-specific dependency in cmtraceopen-parser. +- [ ] No raw SCCM ParserKind added. +- [ ] Artifact name and source-code file are distinct. +- [ ] Unknown signals survive extraction. +- [ ] Unvalidated key/version cannot become Exact or Strong. +- [ ] Invalid/missing offset cannot establish cross-host order. +- [ ] High-confidence cause cannot exist without terminal/corroborating evidence. +- [ ] Insufficient-evidence finding names a bounded next artifact request. +- [ ] Every added serialized type has deterministic fixtures and round-trip tests. diff --git a/docs/superpowers/plans/2026-07-30-sccm-diagnostics-program.md b/docs/superpowers/plans/2026-07-30-sccm-diagnostics-program.md new file mode 100644 index 000000000..626da740f --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-sccm-diagnostics-program.md @@ -0,0 +1,178 @@ +# SCCM Diagnostics Program Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver a pure-Rust, evidence-first SCCM diagnostic layer that explains a client or server workflow with cited evidence, explicit coverage gaps, and conservative confidence. + +**Architecture:** Keep CCM as the reusable raw record grammar. Add SCCM artifact classification, normalized evidence, version-aware keys, transactions, rules, and findings in the parser crate; keep all Windows collection and workspace presentation in native/UI layers. Ship the program as independently reviewable vertical slices, then join only validated client/server pairs. + +**Tech Stack:** Rust 1.88, Cargo workspace, cmtraceopen-parser, cmtrace-open Tauri backend, serde, serde_json, chrono, regex, standard Rust tests, existing collector/bundle infrastructure, native Windows test host for collection validation. + +## Global Constraints + +- The parser crate must remain pure Rust and compile for wasm32-unknown-unknown. +- Do not add filesystem, Windows API, Tauri, async runtime, event-log, WMI, registry, database, or network dependencies to cmtraceopen-parser. +- Reuse the CCM parser as the raw grammar. Do not add SCCM-specific ParserKind values solely because a source uses CCM syntax. +- Every diagnosis must distinguish symptom, confirmed failure, blocked/deferred state, likely contributor, and insufficient evidence. +- Every diagnosis must carry evidence references, scope/role, phase, severity, confidence, stable correlation keys, and a minimal next-artifact request. +- Missing, access-denied, capped, skipped, malformed, and unsupported sources are explicit coverage states. Absence never proves a success or a failure. +- Run identifier, signal, and correlation extraction only after logical-record framing. Never derive a finding from a physical-line fragment. +- Use UTC-normalized ordering only when the source record has a valid offset; retain original timestamp text and offset for display. +- Treat key patterns as versioned heuristics. Preserve reported ConfigMgr version where available and downgrade unknown/unvalidated patterns rather than guessing. +- Do not commit customer logs, tenant data, real hostnames, user names, SIDs, serials, secrets, deployment IDs, or private domains. Use synthetic/sanitized fixtures with obvious test values. +- Preserve public serialized field compatibility. New SCCM types are additive and use camelCase serde names. +- Make each production behavior change through a demonstrated red-green test cycle. +- Keep SCCM plans and source changes isolated on branch codex/parser-family-skeleton until a separately approved implementation branch is created. +- Native Windows is the acceptance boundary for source discovery, bundle capture, registry exports, and rotated-log collection. macOS can verify pure parser behavior only. + +--- + +## Program File Structure + +The individual plans below own the implementation detail. This file is the program contract and review sequence. + +| Plan | Issues | Owns | +| --- | --- | --- | +| 2026-07-30-sccm-diagnostic-spine.md | #318 | Parser-owned common models, privacy-aware evidence, typed signals, source catalog, classification, and test corpus primitives. | +| 2026-07-30-sccm-client-intake-and-core.md | #319, #320, #321, #322, #323 | Client collection, health/location, policy, app/content, and update transaction analyzers. | +| 2026-07-30-sccm-client-extended.md | #324, #325, #326 | Task Sequence, inventory/compliance/metering, and co-management/scripts/notification analyzers. | +| 2026-07-30-sccm-server-intake-and-core.md | #335, #327, #328, #329, #330 | Server role intake plus site core, MP, DP/content, and SUP/WSUS analyzers. | +| 2026-07-30-sccm-server-extended.md | #331, #332, #334 | Hierarchy/replication, Provider/Admin Service, and advanced-role source-contract catalog. | +| 2026-07-30-sccm-cross-side-correlation.md | #333 | Pairwise client-to-MP and client-to-DP correlation, then incremental expansion. | + +## Dependency and Review Graph + +~~~text +#318 shared diagnostic spine + | + +--> #319 client intake --> #320 health/location + | +--> #321 policy + | +--> #322 apps/content + | +--> #323 updates + | +--> #324 TS / #325 inventory / #326 management + | + +--> #335 server intake --> #327 site core + +--> #328 MP + +--> #329 DP/content + +--> #330 SUP/WSUS + +--> #331 hierarchy + +--> #332 Provider/Admin Service + +--> #334 advanced-role contracts + +validated #321 + #328 --------------> #333 policy-to-MP correlation +validated #322 + #329 --------------> #333 content-to-DP correlation +validated #323 + #330 --------------> #333 update/SUP correlation expansion +~~~ + +## Program-Level Review Gates + +### Gate A: Shared Contract Gate + +- [ ] Confirm #318 supplies serializable SCCM models without importing native dependencies. +- [ ] Confirm all required coverage states round-trip through JSON and preserve stable names. +- [ ] Confirm evidence IDs are deterministic for the same sorted artifact bundle. +- [ ] Confirm redaction maintains correlation-safe handles while withholding raw user/context values. +- [ ] Confirm unknown signal tokens are preserved as signals rather than discarded because they are absent from error_db. +- [ ] Confirm a malformed or unknown-version key extraction lowers confidence and emits a coverage/evidence gap. + +### Gate B: Intake Gate + +- [ ] Confirm #319 collects the named client core bundle plus current and rotated logs deterministically. +- [ ] Confirm #335 records host/role/path provenance and does not report a missing default path as a broken role. +- [ ] Confirm a deliberately incomplete captured bundle emits coverage states for every expected source. +- [ ] Confirm no collector test requires a customer environment; native validation uses an explicit developer-supplied SCCM lab. + +### Gate C: Workflow Gate + +- [ ] Each workflow has at least one completed, one confirmed terminal, one blocked/deferred, one contradictory, one incomplete, one rotation, and one malformed fixture scenario. +- [ ] Each high-confidence finding cites the terminal/corroborating evidence. A red log entry alone may create only a symptom. +- [ ] Each workflow returns the last confirmed successful phase and the smallest next artifact bundle when evidence stops. +- [ ] Each workflow passes pure parser tests on macOS and its native collection validation on the development SCCM server/client when that lab becomes available. + +### Gate D: Cross-Side Gate + +- [ ] #333 starts with independently testable policy-to-MP and content-to-DP pairs. +- [ ] Cross-side joins require stable compatible keys and role topology; time-only joins remain low confidence. +- [ ] Conflicting timestamp, invalid offset, missing source, and unrelated same-minute server-error fixtures never result in a high-confidence cause. +- [ ] The correlation output remains usable for a client-only or server-only bundle and names the missing counterpart evidence. + +## Program Tasks + +### Task 1: Establish the common diagnostic spine before any workflow module + +**Plan:** 2026-07-30-sccm-diagnostic-spine.md + +**Issue:** #318 + +- [ ] Execute every task in the spine plan through its parser-only verification command. +- [ ] Review serialized JSON snapshots for schema stability, redaction, and no accidental raw context export. +- [ ] Commit only files owned by #318 with a focused message such as feat(sccm): add diagnostic evidence contracts. +- [ ] Update #318 with fixture/test evidence and the exact commit after native-independent verification passes. + +### Task 2: Run client and server intake foundations in parallel after the spine lands + +**Plans:** 2026-07-30-sccm-client-intake-and-core.md and 2026-07-30-sccm-server-intake-and-core.md + +**Issues:** #319 and #335 + +- [ ] Start client intake only after the artifact/coverage types from #318 are public and tested. +- [ ] Start server intake only after the same shared types are public and tested. +- [ ] Keep native collection changes segregated by client versus server artifact roots to prevent role assumptions leaking across products. +- [ ] On the development SCCM server, capture only synthetic/lab incident evidence and produce sanitized fixture manifests before committing fixture data. +- [ ] Do not close either intake issue until a deliberately incomplete bundle proves explicit coverage behavior. + +### Task 3: Deliver client workflows in value order + +**Plans:** 2026-07-30-sccm-client-intake-and-core.md and 2026-07-30-sccm-client-extended.md + +**Issues:** #320 through #326 + +- [ ] Land health/location first for the clearest prerequisite vocabulary, but keep analyzer implementation dependencies at #318/#319 unless a reviewed public fact contract adds a real dependency. +- [ ] Land policy early so applications/content and updates can consume validated policy facts when present; neither #322 nor #323 may require policy output to remain conservative on a partial bundle. +- [ ] Land application/content and updates as separate transactions; share only the common models and utility extractors. +- [ ] Land Task Sequence after transaction boundaries are proven; its relocation and execution-instance contract needs separate review. +- [ ] Land inventory/compliance/metering and client-management work as scoped state machines, not catch-all parsers. + +### Task 4: Deliver server workflows in role order + +**Plans:** 2026-07-30-sccm-server-intake-and-core.md and 2026-07-30-sccm-server-extended.md + +**Issues:** #327 through #332 and #334 + +- [ ] Land site core/status early so role health evidence can qualify later downstream review, but do not make MP/DP/SUP parser implementation wait unless they consume an approved public context fact. +- [ ] Land MP and DP/content as independent role analyzers so the two first #333 pairs can proceed as soon as their own client/server contracts are stable. +- [ ] Land DP/content and SUP as separate role analyzers with independent content/update identifiers. +- [ ] Land hierarchy/replication and Provider/Admin Service after the role-specific transaction model is stable. +- [ ] Keep #334 a catalog/fixture gate. Open a dedicated advanced-role implementation issue only after a verified source grammar and terminal-state contract exist. + +### Task 5: Deliver cross-side correlation incrementally + +**Plan:** 2026-07-30-sccm-cross-side-correlation.md + +**Issue:** #333 + +- [ ] Begin policy-to-MP correlation after #321 and #328 are independently verified. +- [ ] Begin content-to-DP correlation after #322 and #329 are independently verified. +- [ ] Add software-update/SUP correlation only after #323 and #330 are independently verified. +- [ ] Require one review focused solely on false-causality defenses before adding any new cross-side rule family. + +## Standard Verification Commands + +Run the narrowest command while implementing each task, then run the relevant aggregate checks before its commit: + +~~~bash +cargo test -p cmtraceopen-parser +cargo test -p cmtrace-open --test esp_diagnostics_sources +cargo test -p cmtrace-open --test parser_expanded_corpus +cargo fmt --check --all +cargo clippy -p cmtraceopen-parser --all-targets -- -D warnings +git diff --check +~~~ + +Run Windows-only collector/source checks on the SCCM lab only after the pure parser suite is green. Record the lab Configuration Manager version, role topology, capture time zone, synthetic scenario, and redaction procedure in the fixture metadata; never record credentials or live customer identifiers. + +## Completion Definition + +- [ ] Every issue has a committed plan-backed implementation, code review, and linked fixture/test evidence. +- [ ] Every analyzer is conservative by construction and produces evidence-backed output on incomplete bundles. +- [ ] Dedicated SCCM Client and Server workspace work starts only after the shared snapshot/finding API has at least one stable client and one stable server workflow plus a stable correlated pair. diff --git a/docs/superpowers/plans/2026-07-30-sccm-server-extended.md b/docs/superpowers/plans/2026-07-30-sccm-server-extended.md new file mode 100644 index 000000000..7ae3a0734 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-sccm-server-extended.md @@ -0,0 +1,454 @@ +# SCCM Server Extended Roles Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Deliver issues #331, #332, and #334 as evidence-first SCCM Server extensions for hierarchy/replication, Provider/Admin Service, and a rigorously gated catalog of advanced server-role sources. + +**Architecture:** Reuse the common SCCM schema (#318) and server intake/topology contract (#335). Hierarchy/replication and Provider/Admin Service each get a narrow role-local source catalog, transaction key model, state reducer, test corpus, and conservative findings. #334 is deliberately a source-contract and fixture-discovery program; it does not turn every known SCCM log into an unsupported optimistic parser. + +**Tech Stack:** Rust 1.88, cmtraceopen-parser, cmtrace-open native capture adapter, serde/serde_json, raw CCM/IIS parser families, synthetic corpus, Windows SCCM Server development environment for role/configured-path validation. + +## Global Constraints + +- #318 and #335 are hard prerequisites. #327's site-core vocabulary may be cited where useful but neither #331 nor #332 may use it as an unproven causal shortcut. +- This plan covers #331, #332, and #334 only. It does not implement client workflows, MP/DP/SUP core workflows, cross-side correlation, UI, SQL/database analytics, or any direct service/API interaction in the parser crate. +- The pure parser must stay platform-neutral and wasm-compatible. Windows discovery/registry/service/IIS configuration belongs in native capture only. +- No new ParserKind is introduced. CCM remains a raw grammar; semantic source classification happens from server manifest role/provenance plus catalogued basename. +- A site link, provider endpoint, Admin Service endpoint, cloud role, PXE role, reporting role, or certificate role is never inferred solely from a default directory or a filename. It must be observed/configured or left as a coverage/candidate state. +- Do not collapse recipient/remote-site, host, role, path, source version, message ID, request ID, caller identity, HTTP URL, certificate reference, or token-like fields into public output. Preserve only redacted/opaque correlation handles when #318 explicitly permits them. +- Exact profile-validated keys and topology are required for high-confidence linking. A same-minute replication error, provider error, or HTTP error cannot be blamed for a different client/server workflow by timing alone. +- Advanced-role source discovery must be source-card driven. No semantic reducer can merge until a curated source card, sanitized fixtures, terminal-state grammar, version scope, and explicit issue dependency have been reviewed. +- Native development-server results are validation evidence. They must not be committed wholesale or converted directly into fixture logs. +- Every expected source absent/access-denied/capped/skipped/unsupported/parse-failed state is explicit. No absence proves health, a disabled role, or a root cause. + +--- + +## Scope, Dependencies, and Delivery Order + +| Issue | Outcome | Dependencies | Review boundary | Follow-on | +| --- | --- | --- | --- | --- | +| #331 | Site-to-site/hierarchy/replication transactions | #318 + #335; optional #327 context | site-link key/topology/ordering and no false remote cause | later controlled correlation only when a pair is designed | +| #332 | Provider and Admin Service request transactions | #318 + #335 | caller/privacy, provider vs API layers, source coverage | future console/API workspace support | +| #334 | Advanced role source-card catalog and fixture gate | #318 + #335 | documented source evidence before code | one narrowly scoped implementation issue per validated source family | + +#331 and #332 can be developed in parallel after #335's server manifest contract is frozen. #334 runs continuously alongside them but must not turn an observation into a production analyzer. A source card accepted under #334 creates a follow-up implementation issue with its own files, fixture matrix, and terminal criteria; #334 itself remains a catalog/triage issue. + +## File Structure and Ownership + +~~~text +crates/cmtraceopen-parser/ +├── src/sccm/server/windows/ +│ ├── hierarchy_and_replication.rs # #331 +│ ├── provider_and_admin_service.rs # #332 +│ ├── advanced_roles.rs # #334 source-card catalog only +│ ├── catalog.rs # #335 shared role/source declarations +│ └── mod.rs +├── tests/ +│ ├── sccm_server_hierarchy_and_replication.rs +│ ├── sccm_server_provider_and_admin_service.rs +│ ├── sccm_server_advanced_roles_catalog.rs +│ └── fixtures/sccm/server/ +│ ├── hierarchy_and_replication// +│ ├── provider_and_admin_service// +│ └── advanced_roles/ +│ ├── source-cards/ +│ └── catalog-fixtures/ +src-tauri/ +├── src/sccm/collector/discovery.rs # role/config candidate observation only +├── src/sccm/collector/engine.rs # capture only admitted advanced sources +├── src/sccm/collector/manifest.rs # source-card/capture provenance +└── tests/sccm_server_collection.rs +docs/ +└── sccm/ + ├── source-catalog/advanced-roles.md + └── validation/server-extended-lab-checklist.md +~~~ + +The parser source-card data may be a typed Rust table or a versioned fixture data file, but it must have a single owner. Do not make an unreviewed native discovery list silently diverge from parser catalog metadata. Do not expose raw source-card research/URLs in customer-facing analysis output. + +## Common Review Contract + +All #331/#332 transactions/facts must be keyed and cited. Each accepted finding must state: + +1. role/topology scope; +2. named workflow phase and last evidenced good phase; +3. evidence refs and profile/source version; +4. capture/coverage limits; +5. exact/strong versus candidate key basis; +6. class and confidence; +7. smallest next artifact request when evidence is insufficient. + +Before a new advanced source is promoted past #334, reviewers must be able to answer: + +| Question | Required proof | +| --- | --- | +| What exact role/source is this? | Source card with observed/configured role provenance and declared basename/path-class candidates | +| Which raw grammar frames it? | CCM/IIS/plain/etc. parser family plus logical-record rule | +| What version scope is known? | Sanitized manifest/source version plus fixture profile IDs | +| What does healthy look like? | One minimal success fixture with cited terminal/steady-state evidence | +| What is a terminal failure? | One minimal failure fixture with source-specific terminal evidence, not a generic error token | +| How are transactions keyed? | Versioned stable key extraction rule plus collision/adversarial fixture | +| What coverage is required? | Explicit mandatory/optional source group and absent/access/cap/skip behavior | +| What data must redact? | Source-card privacy fields and exported projection test | +| What issue owns code? | A new linked issue after the source card passes review | + +## Task 1: Define #331 hierarchy and replication source/key contracts + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy_and_replication.rs +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs +- Create: crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication.rs +- Create: fixtures hierarchy-and-replication/healthy-link, sender-failure, receiver-processing-failure, backlog-retry, topology-mismatch, clock-offset-unknown, rotation-boundary, absent-remote-source, and incomplete +- Modify native discovery/manifest only after pure source contract proves required data cannot be supplied by #335 + +**Consumes:** #318 shared evidence/key/time/finding/redaction contracts; #335 role/topology/server manifest intake; catalogued evidence such as replmgr.log, rcmctrl.log, sender.log, despool.log, and only other observed role sources. + +**Produces:** A table-driven hierarchy/replication source catalog and safe link/transaction candidate grouping. It does not yet emit final diagnosis state transitions. + +### Topology/key rule + +The transaction identifier must contain a profile-validated site-link/message/replication key plus compatible origin/target site/role topology. A remote host or site code alone is not enough. The source catalog records whether evidence is origin-side, target-side, or topology-only. Cross-site timestamp comparison requires valid offset provenance; unknown/invalid offsets prevent high-confidence ordering across hosts. + +- [ ] **Step 1: Write source/topology grouping tests first** + +Require tests that: + + - a healthy link uses exact same link/message key plus compatible source/target topology; + - two same-minute sender failures for different remote sites remain separate; + - a record with an unknown/missing offset cannot establish sender-before-receiver causality; + - a site-code-looking string in a generic message cannot create a hierarchy link; + - an absent remote-side artifact is a coverage gap with a bounded remote source request, not a remote-site failure; + - rotated fragments retain direction/path/role provenance and partial fragments cannot create a message/link key; + - reordering artifacts gives byte-identical candidate output. + +- [ ] **Step 2: Run the narrow test red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_hierarchy_and_replication source_and_topology +~~~ + +Expected: FAIL because no hierarchy module/catalog/API exists. + +- [ ] **Step 3: Implement source admission and candidate grouping** + +Create source-specific fact extraction for only declared replication/log families. Preserve direction, safe site handles, message/link identifiers, phase candidate, terminality candidate, timestamp provenance, and evidence reference. Use the #318 versioned key registry; raw values with unvalidated profile/version become low-confidence candidates plus key-extraction gaps. Do not read site configuration/native state in this crate. + +- [ ] **Step 4: Make contract tests green** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_hierarchy_and_replication source_and_topology +cargo test --locked -p cmtraceopen-parser --test sccm_server_intake +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check +~~~ + +- [ ] **Step 5: Commit #331 source/key boundary separately** + +~~~bash +git add crates/cmtraceopen-parser/src/sccm/server crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication +git commit -m "feat(sccm): model hierarchy replication evidence" +~~~ + +## Task 2: Implement #331 hierarchy and replication state reducers + +**Files:** + +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy_and_replication.rs +- Modify: crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication.rs +- Modify: #331 fixture expected files + +**Consumes:** Source/key facts from Task 1. + +**Produces:** Per-link/message replication analyses with conservative sender, receiver, retry/backlog, and coverage findings. + +### State contract + +~~~text +Initiate -> QueueOrSerialize -> Send -> Receive -> Process -> Acknowledge -> HealthyOrTerminal +~~~ + +This sequence models role-local evidence; it does not promise every topology emits every phase. A retry/backlog remains blocked/deferred or symptom unless source-specific terminal evidence proves failure. A later acknowledgement demonstrates recovery only under compatible exact link/message keys and ordering provenance. + +- [ ] **Step 1: Add failing phase/terminal fixture tests** + +Include: + + - healthy end-to-end link with cited acknowledgment; + - terminal send failure; + - receiver/processing failure after an evidenced send; + - retry/backlog with no terminal record; + - mismatched topology/key that stays unlinked; + - conflicting clocks / invalid offset downgraded from causal diagnosis; + - missing remote evidence requesting only the relevant role/source; + - a later success for the same key showing recovery; + - logical record/rotation boundary that never becomes a terminal transaction. + +- [ ] **Step 2: Run full #331 target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_hierarchy_and_replication +~~~ + +- [ ] **Step 3: Implement per-link reducer and finding rules** + +Use stable maps/sorts by exact normalized link/message/topology key. Advance phases only on profile-recognized facts. Retain contradictory evidence. A high-confidence confirmed failure needs terminal origin/target evidence or independent corroboration with compatible topology—not mere absence of an acknowledgement. For insufficient evidence, request a bounded counterpart source such as the remote sender/receiver artifact, never broad site/server capture. + +- [ ] **Step 4: Run complete parser gates and commit** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_hierarchy_and_replication +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy_and_replication.rs crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication +git commit -m "feat(sccm): analyze hierarchy replication transactions" +~~~ + +Update #331 with supported source/profile list and explicit limits around remote environment coverage. + +## Task 3: Define #332 Provider and Admin Service source/privacy/key contracts + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/provider_and_admin_service.rs +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs +- Create: crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs +- Create: fixtures provider-and-admin-service/provider-success, provider-authz-denied, provider-query-failure, provider-timeout, admin-service-success, admin-service-auth-failure, admin-service-backend-failure, iis-supplemental, privacy-redaction, rotation-boundary, incomplete + +**Consumes:** #318 redaction/signal/key/finding contracts; #335 server role/topology source metadata; curated provider/Admin Service sources such as smsprov.log, AdminService.log, and explicitly catalogued IIS supplement only when observed. + +**Produces:** A privacy-safe source catalog and request candidate grouping that distinguishes Provider from Admin Service layers before final workflow findings. + +### Request key and privacy rule + +A request transaction needs a profile-validated request/correlation ID, operation/query handle, and compatible role/endpoint context. Caller identity, query text, URL parameters, authorization header/token, tenant/domain host, and certificate details are never public key values. If correlation requires an identity-like field, use the #318 deterministic redacted handle and test that raw form is absent from exports. + +- [ ] **Step 1: Write failing source/privacy tests** + +Assert: + + - Provider and Admin Service source records produce different role/workflow candidates; + - a request cannot be keyed only by endpoint path or same-minute timestamp; + - authz/authorization evidence redacts raw caller/token-like content; + - unrecognized IIS source remains supplemental/unsupported, not an Admin Service transaction; + - missing provider source and missing Admin Service source request the exact distinct artifact group; + - same request-like identifier from incompatible topology/role cannot merge; + - rotation fragment/unknown version cannot emit an exact request key. + +- [ ] **Step 2: Run red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_provider_and_admin_service source_privacy_and_keys +~~~ + +- [ ] **Step 3: Implement layered source/fact extraction** + +Keep separate private fact kinds for Provider service, Admin Service, and supplementary IIS. Each contains sanitized request key candidate, operation category, phase candidate, terminality, signals, evidence ref, and redaction class. Use source/version profile admission before emitting exact keys. Do not log/query SQL/provider/database/API data or make network calls. + +- [ ] **Step 4: Run contract gates and commit** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_provider_and_admin_service source_privacy_and_keys +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/server crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service +git commit -m "feat(sccm): model provider and admin service evidence" +~~~ + +## Task 4: Implement #332 Provider/Admin Service state reducers + +**Files:** + +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/provider_and_admin_service.rs +- Modify: crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs +- Modify: #332 fixture expected files + +**Consumes:** Task 3 fact candidates and shared finding builder. + +**Produces:** Layer-specific request transaction findings with strict privacy projection. + +### State contracts + +~~~text +Provider: Receive -> AuthenticateOrAuthorize -> ExecuteProviderOperation -> Respond -> RecordOutcome +AdminService: Receive -> AuthenticateOrAuthorize -> Route -> ExecuteBackendOperation -> Respond -> RecordOutcome +~~~ + +A 4xx/5xx-like signal cannot alone determine which state happened. A terminal failure needs source-specific completion/error evidence. A missing IIS supplement cannot make a provider/Admin Service result fail; it may lower confidence or request the narrow supplemental source only where the rule truly requires it. + +- [ ] **Step 1: Add failing operational fixtures** + +Require Provider success, explicit authorization deny, provider query/operation failure, timeout/incomplete result, Admin Service success, auth failure, backend failure, optional IIS correlation, privacy redaction, mismatched request keys, and incomplete source coverage. Assert phase, last success, class, confidence, evidence refs, redacted output, and minimal next request. + +- [ ] **Step 2: Run full #332 test target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_provider_and_admin_service +~~~ + +- [ ] **Step 3: Implement separate request reducers** + +Group facts only by safe exact normalized keys. Enforce layer/role topology. Use monotonic phase progression and source-specific terminal facts. Keep client/console impact outside the conclusion: output says what the Provider/Admin Service evidence proves, not “the console/user failed because of this” unless future paired evidence supports it. + +- [ ] **Step 4: Add privacy/determinism regressions, verify, and commit** + +Test byte-identical public output on reordered artifacts, raw sensitive field absence, raw snapshot immutability after redacted projection, invalid offsets lowering cross-artifact confidence, and generic unknown error retention. + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_provider_and_admin_service +cargo test --locked -p cmtraceopen-parser --test sccm_server_intake +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/server/windows/provider_and_admin_service.rs crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service +git commit -m "feat(sccm): analyze provider and admin service transactions" +~~~ + +## Task 5: Build #334 advanced-role source-card catalog + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/advanced_roles.rs +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +- Create: crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs +- Create: crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/*.json +- Create: crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/{valid,missing-required-field,unvalidated-source,redaction-required}/{source-card.json,expected.json} +- Create: docs/sccm/source-catalog/advanced-roles.md +- Modify native discovery only to preserve a candidate source card ID/capture state; no semantic rule belongs there + +**Consumes:** #318 schema/version/redaction types and #335 observed role/capture manifest contract. + +**Produces:** Versioned, reviewable source cards that prevent unvalidated role logs from entering production analyzers. + +### Initial candidate families + +The catalog starts with families such as: + +| Source-card family | Candidate examples | Status at #334 start | +| --- | --- | --- | +| OS deployment/PXE | smspxe.log, PXE/OSD role logs observed in the lab | candidate only; no reducer | +| Client notification/BGB | server-side BGB/notification logs observed in the lab | candidate only; distinguish from client notification | +| Cloud/service connection | CloudMgr, service connector, CMG-related logs observed/configured | candidate only; privacy review required | +| Reporting | catalogued reporting service logs observed/configured | candidate only | +| Certificate enrollment/PKI | explicitly observed SCCM enrollment/certificate role logs | candidate only; high privacy sensitivity | +| SQL/database/export | explicit server-side supplementary diagnostics | unsupported by parser in this phase unless a dedicated source contract is approved | + +A source card must not state that a candidate exists merely because the file name is familiar. The source needs observed/configured role provenance in a sanitized lab or authoritative source mapping before promotion. + +- [ ] **Step 1: Write failing source-card schema tests** + +Create a typed card model/JSON fixture that fails unless it includes: card ID/version, role/family, candidate basenames/path classes, raw parser family, source version scope, mandatory/optional capture classification, rotation policy, privacy/redaction classes, expected healthy evidence description, terminal failure evidence description, correlation/key policy, fixture IDs, owner issue, and promotion status. + +Test malformed/missing fields, unknown parser family, candidate-only source trying to declare a production reducer, raw sensitive field projection, deterministic sorted catalog, and deprecation/supersession semantics. + +- [ ] **Step 2: Run source-card test red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_advanced_roles_catalog +~~~ + +- [ ] **Step 3: Implement typed source cards and admission states** + +Use explicit states such as Candidate, Observed, FixtureValidated, RuleValidated, and Deferred. Only RuleValidated may be exported to a production semantic catalog, and a corresponding linked implementation issue must exist. Candidate/Observed cards can appear in diagnostics as capture capability requests but cannot create a transaction or failure. Preserve unknown cards as data; do not panic or silently accept them. + +- [ ] **Step 4: Add initial cards and documentation** + +Write source cards only for families with the required evidence available. For each card, document what has been observed versus still unknown, source permission/capture limits, redaction needs, and exact next evidence to promote it. Do not create filler cards with generic phrases such as “parse log and identify errors.” + +- [ ] **Step 5: Verify and commit #334 catalog gate** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_advanced_roles_catalog +cargo test --locked -p cmtraceopen-parser --test sccm_server_intake +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/server crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles docs/sccm/source-catalog/advanced-roles.md +git commit -m "feat(sccm): catalog advanced server role sources" +~~~ + +## Task 6: Validate #331–#334 against the development SCCM Server and issue review gates + +**Files:** + +- Create: docs/sccm/validation/server-extended-lab-checklist.md +- Modify: issues #331, #332, #334 with exact fixture/test/validation evidence +- Add a follow-up GitHub issue for each source card promoted past Candidate/Observed + +**Consumes:** Complete pure tests, native SCCM server collector, and authorized lab access. + +**Produces:** Accurate evidence classification: pure contract proven, native test-double proven, Windows lab observed, or explicitly pending. + +- [ ] **Step 1: Run focused and aggregate parser checks** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_hierarchy_and_replication +cargo test --locked -p cmtraceopen-parser --test sccm_server_provider_and_admin_service +cargo test --locked -p cmtraceopen-parser --test sccm_server_advanced_roles_catalog +cargo test --locked -p cmtraceopen-parser +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo fmt --check --all +git diff --check +~~~ + +- [ ] **Step 2: Run native collection regressions** + +~~~bash +cargo test --locked -p cmtrace-open --test sccm_server_collection --features sccm-diagnostics +cargo test --locked -p cmtrace-open --test sccm_client_intake --features sccm-diagnostics +cargo test --locked -p cmtrace-open --test esp_diagnostics_sources --all-features +cargo test --locked -p cmtrace-open --test parser_expanded_corpus --all-features +cargo clippy --locked -p cmtrace-open --all-targets --all-features -- -D warnings +~~~ + +- [ ] **Step 3: Use the lab in discovery-first mode** + +Confirm dev-only host, version, observed role topology, selected source-card candidate, configuration path, safe synthetic scenario, redaction, capture caps, and data retention. First record discovery/capability results. Capture only a bounded approved source group. An unobserved or access-denied source becomes source-card evidence/capture state; it does not justify a broad privilege increase or parser guess. + +- [ ] **Step 4: Promote source cards only with precise artifacts** + +A Candidate becomes Observed only with sanitized role/path/version provenance. It becomes FixtureValidated only with minimum success/failure/coverage fixtures. It becomes RuleValidated only after exact key/phase/terminal tests pass and an implementation issue/PR is linked. Keep rejected/unsupported source cards with a reason, instead of deleting their evidence. + +- [ ] **Step 5: Write individual issue evidence** + +#331 must list source/link profile versions and remote-side coverage limitations. #332 must list redaction tests and layers supported. #334 must list each card's state and linked follow-up issue, not claim broad role support. Do not close any issue because a lab exists; close only when its enumerated fixtures/tests/acceptance evidence are present. + +## Exit Criteria + +### #331 Hierarchy/replication + +- [ ] Link/message/topology keys prevent cross-site and same-minute false joins. +- [ ] Healthy, terminal, retry/backlog, recovery, incompatible topology, unknown offset, rotation, and absent counterpart fixtures pass. +- [ ] High-confidence root-cause wording requires compatible terminal/corroborating role evidence. + +### #332 Provider/Admin Service + +- [ ] Provider and Admin Service source layers stay separate and key/privacy gated. +- [ ] Public/redacted exports contain no caller/query/token/URL/certificate-like raw fields. +- [ ] Successful/auth/authorization/backend/timeout/incomplete cases have exact contracts. + +### #334 Advanced roles + +- [ ] Source-card schema and promotion state rules are typed, tested, deterministic, and privacy aware. +- [ ] Only RuleValidated sources can enter a semantic analyzer, each with a linked implementation issue. +- [ ] Candidate/Observed sources remain useful capture guidance without claiming support or diagnosis. diff --git a/docs/superpowers/plans/2026-07-30-sccm-server-intake-and-core.md b/docs/superpowers/plans/2026-07-30-sccm-server-intake-and-core.md new file mode 100644 index 000000000..2c65230ab --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-sccm-server-intake-and-core.md @@ -0,0 +1,578 @@ +# SCCM Server Intake and Core Workflow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Deliver issues #335, #327, #328, #329, and #330 as a role-aware SCCM Server evidence bundle plus site-core/status, Management Point, Distribution Point/content, and Software Update Point/WSUS diagnostics. + +**Architecture:** The parser crate classifies supplied server artifacts and reduces role-local evidence into transactions/findings using the shared #318 contracts. The native backend discovers configured/observed server roles and bounded candidate sources, writes a versioned SCCM server manifest, and preserves role/topology/provenance. No analyzer assumes a default server path proves a role exists; no client/server causality is claimed until #333 consumes validated pairs. + +**Tech Stack:** Rust 1.88, cmtraceopen-parser, cmtrace-open, serde/serde_json, existing CCM parser and IIS parser, current generic collector only as a compatibility reference, Windows Server SCCM development environment for native capture acceptance, synthetic fixture corpus. + +## Global Constraints + +- #318 is the parser/API prerequisite. #335 is the intake prerequisite for #327–#330. #319 client intake may proceed in parallel but is not a substitute for server evidence. +- This plan covers server intake/site core/MP/DP/SUP only. It does not cover hierarchy/replication, Provider/Admin Service, advanced roles, UI workspaces, or cross-side diagnosis. +- SCCM server log formats still use raw parser families such as CCM and IIS W3C. Do not add an SCCM ParserKind, duplicate CCM framing, or make every server role a parser implementation. +- The pure parser crate stays free of filesystem, registry, WMI, IIS configuration, SQL, service-control, event-log, network, and Tauri dependencies. Those belong exclusively in the native collector adapter. +- Existing generic ArtifactStatus has three serialized values. Do not change it or overload it with server role/coverage semantics in this plan; use an additive, versioned SCCM server manifest schema/extension and a documented tolerant reader. +- Capture state must preserve Captured, Absent, AccessDenied, Capped, Skipped, Unsupported, and ParseFailed separately. A default candidate root not found is Absent for that candidate—not proof that the role is absent, unhealthy, or uninstalled. +- Preserve server host, role, configured source path, source version, rotation lineage, collection time, encoding, and byte limit provenance. Artifact basename and CCM file= code-origin attribute are distinct and must stay distinct. +- Collections must preserve distinct log/rotation paths; the generic collector's filename-only destination cannot be used where two role logs could collide. +- Analyze only complete logical records. A partial first/last rotation record, malformed record, unknown profile version, or invalid timestamp offset may create a coverage/parse gap or low-confidence symptom, never a terminal role diagnosis. +- Findings name the last evidenced good hop and a bounded next artifact request. An error-looking server record alone cannot establish a root cause for a client. +- The new SCCM Server dev environment is a validation source, not a blocker. Parser/corpus work proceeds against synthetic inputs. Native acceptance remains pending until the lab is authorized and exercised. +- Never commit live site names, host names, users, domain names, certificates, URLs, database names, package IDs, client identifiers, credentials, or customer logs. Use LAB-CM01, LAB-MP01, LAB-DP01, the three-character site code LAB, and synthetic keys. + +--- + +## Issue Sequencing + +| Issue | Deliverable | Must follow | Can proceed in parallel with | Unlocks | +| --- | --- | --- | --- | --- | +| #335 | Server source catalog, role/topology manifest, bounded capture, pure intake | #318 | #319 | #327–#334 | +| #327 | Site core/component/status transactions | #335 | #328/#329 analysis implementation after source contract | Server role health vocabulary | +| #328 | MP request/auth/registration/policy/location transactions | #335; #327 findings may enrich but do not block | #329 | #333 policy-to-MP pair after #321 | +| #329 | DP/package/content distribution transactions | #335 | #328/#330 | #333 content-to-DP pair after #322 | +| #330 | SUP/WSUS synchronization/health transactions | #335 | #327–#329 | later update/SUP pair after #323 | + +Land #335 as pure catalog/manifest reader first, then native capture in a separate commit if possible. #327 establishes site/role status vocabulary and should be reviewed before declaring a downstream role unavailable. #328 and #329 may develop from frozen server intake fixtures in parallel. #330 is server-local: do not force it to wait for client update analysis or server correlation. + +## File Structure and Ownership + +~~~text +crates/cmtraceopen-parser/ +├── src/sccm/ +│ ├── mod.rs +│ ├── models.rs # #318 shared wire models only +│ ├── catalog.rs # shared source/role catalog primitive +│ └── server/ +│ ├── mod.rs # server public façade +│ └── windows/ +│ ├── mod.rs +│ ├── catalog.rs # server role/source/bundle declaration; no I/O +│ ├── intake.rs # manifest/artifact classification + coverage +│ ├── site_core.rs # #327 +│ ├── management_point.rs # #328 +│ ├── distribution_point.rs# #329 +│ └── software_update_point.rs # #330 +├── tests/ +│ ├── sccm_server_intake.rs +│ ├── sccm_server_site_core.rs +│ ├── sccm_server_management_point.rs +│ ├── sccm_server_distribution_point.rs +│ ├── sccm_server_software_update_point.rs +│ └── fixtures/sccm/server/ +│ ├── README.md +│ ├── intake//{manifest.json,evidence/,expected.json} +│ ├── site_core//{manifest.json,evidence/,expected.json} +│ ├── management_point//{manifest.json,evidence/,expected.json} +│ ├── distribution_point//{manifest.json,evidence/,expected.json} +│ └── software_update_point//{manifest.json,evidence/,expected.json} + +src-tauri/ +├── Cargo.toml +├── src/sccm/ +│ ├── mod.rs +│ ├── bundle.rs # shared SCCM bundle layout/types from #319 +│ ├── manifest.rs # schema v1 reader/writer; extend compatibly +│ └── collector/ +│ ├── mod.rs +│ ├── discovery.rs # Windows/configured-role discovery only +│ ├── engine.rs # bounded capture/collision-safe layout +│ └── manifest.rs # server manifest projection, not generic manifest mutation +├── tests/sccm_server_collection.rs +scripts/collection/ +└── sccm-server-evidence-profile.json # only if a script profile is shipped +references/collection/ +└── sccm-server-evidence-profile.json # byte-for-byte parity with scripts copy +~~~ + +If #319 has already created src-tauri/src/sccm/{intake.rs,bundle.rs,manifest.rs}, reuse those stable types instead of creating a parallel client/server manifest representation. Put Windows-only server role discovery in collector/discovery.rs, behind the same sccm-diagnostics feature or a carefully additive sccm-server-diagnostics feature. Do not wire a desktop command or dedicated workspace in this plan unless the issue explicitly requires a tested callable capture entry point; a native library function is sufficient for the first server capture contract. + +## Server Bundle/Manifest Contract + +The server manifest needs enough information to interpret evidence without querying the lab again. The top-level schema is versioned independently from generic collection manifests: + +~~~json +{ + "sccmManifestVersion": 1, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "rolesObserved": ["siteServer", "managementPoint"], + "siteCode": "LAB" + }, + "artifacts": [{ + "artifactId": "server-mp-get-policy", + "role": "managementPoint", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "MP_GetPolicy.log", + "configuredPath": true, + "rotation": {"kind": "current"}, + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T00:00:00Z", + "relativePath": "evidence/sccm/server/management-point/mp-get-policy/current/MP_GetPolicy.log", + "bytesCopied": 1024 + }] +} +~~~ + +A redacted public export may omit/transform captureHost/paths but must retain role, source ID, capture state, rotation, artifact identity, and an opaque stable handle when correlation needs it. The pure reader must deserialize manifest fields in stable order and map legacy generic artifacts only when their role/source provenance is explicitly supplied; it must not invent a management-point role from a filename alone. + +## Initial Source Catalog and Requiredness + +These are curated candidate groups—not promises that a role or source exists in every installation. Each entry carries role, source parser family, workflow consumers, default requiredness for an incident bundle, rotation behavior, and whether it is an optional supplemental source. + +| Logical artifact | Candidate basenames | Role | Workflow use | Collection rule | +| --- | --- | --- | --- | --- | +| server-sitecomp | sitecomp.log, hman.log, component manager status sources | site server | #327 | current + known rotations; role candidate | +| server-status | statmgr.log, statesys.log, curated status/state sources | site server | #327 | current + known rotations | +| server-mp-auth | MP_GetAuth.log, MP_CliReg.log, MP_RegistrationManager.log | MP | #328 | current + known rotations | +| server-mp-policy | MP_GetPolicy.log, MP_Location.log, mpcontrol.log | MP | #328 | current + known rotations | +| server-mp-iis | catalogued IIS W3C logs or explicitly captured MP web logs | MP | #328 supplemental | optional; no broad IIS tree | +| server-dp-distribution | distmgr.log, PkgXferMgr.log, SMSDPProv.log, PullDP.log when observed | DP/site server | #329 | current + known rotations | +| server-dp-serve | explicitly catalogued DP serving/status source | DP | #329 supplemental | optional until fixture proven | +| server-sup-sync | wsyncmgr.log, wcm.log, WSUSCtrl.log, SUPSetup.log | SUP/WSUS | #330 | current + known rotations | +| server-sup-wsus | explicitly scoped WSUS health/sync log source | SUP/WSUS | #330 supplemental | optional, bounded/capped | +| server-iis-status | curated IIS/status export when role discovery proves scope | MP/SUP/other | supplemental | skipped by default unless incident bundle asks | + +The catalog must accept only declared basenames/rotations for a role. An artifact whose basename overlaps a client log is not a server artifact without role/provenance. Unknown artifacts are retained as unclassified/unsupported manifest evidence; they are not silently discarded or misclassified. + +## Task 1: Implement #335 pure server catalog, manifest reader, and coverage assessment + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/server/mod.rs +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +- Modify: crates/cmtraceopen-parser/src/sccm/mod.rs +- Modify only for common catalog primitives: crates/cmtraceopen-parser/src/sccm/catalog.rs +- Create: crates/cmtraceopen-parser/tests/sccm_server_intake.rs +- Create: crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md +- Create: intake fixtures complete-multi-role, configured-nondefault-path, rotations, multiline, absent-dp, access-denied-mp, capped-sup, skipped-iis, unsupported-db-supplement, and unsorted-manifest + +**Consumes:** #318 shared models, coverage/rotation/time/redaction/key contracts, and the public CCM logical-record path. + +**Produces:** A pure assess_server_intake and normalize_server_bundle contract which classifies supplied artifacts by declared role/source and yields deterministic coverage. It does not enumerate server paths. + +- [ ] **Step 1: Write the intake fixture tests before adding server code** + +Test all fixture cases explicitly: + + - complete-multi-role recognizes site/MP/DP/SUP groups, their roles, paths, and all captured states; + - configured-nondefault-path retains the configured/observed source provenance and never converts it to a missing default path; + - rotations maps current, .lo_, numeric, and timestamped rotations with stable lineage and collision-safe source IDs; + - multiline proves one framed CCM record produces one evidence record with a full line range/rotation provenance; + - absent-dp emits DP coverage gaps but no “DP broken” finding; + - access-denied-mp exposes MP access coverage and a bounded next request without a terminal MP diagnosis; + - capped-sup prevents a truncated log tail from yielding terminal SUP health; + - skipped-iis preserves an intentional optional source skip; + - unsupported-db-supplement preserves unknown/unsupported metadata but cannot enter a role reducer; + - unsorted-manifest results in byte-identical normalized intake output when artifacts are reordered. + +- [ ] **Step 2: Run red before implementation** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_intake +~~~ + +Expected: FAIL because server modules/catalog/manifest intake APIs do not exist. + +- [ ] **Step 3: Add server source declarations and pure intake logic** + +Create a declarative source table. Each entry must state logical ID, allowed role(s), candidate basenames, parser family, rotation parser, requiredness, incident bundles, and workflow consumers. intake.rs must: + +1. verify SccmRole and artifact metadata compatibility; +2. classify known basename plus supported rotation, never a filename alone; +3. preserve configured-path and host/topology evidence; +4. group fragments by logical source and role; +5. retain every individual capture state and calculate workflow coverage without collapsing errors; +6. preserve unknown/unsupported sources separately; +7. call only shared logical-record normalization to construct evidence; +8. stable-sort by role/logical source/path fingerprint/rotation/basename. + +Do not build a role health model or source discovery yet. + +- [ ] **Step 4: Add backward/forward compatibility tests** + +Add tests that a legacy generic manifest is accepted only as an explicitly incomplete server bundle when supplied through an adapter; absent SCCM fields remain gaps. Test unknown external enum strings/fields survive tolerant deserialization via a documented unknown form, where the #318 schema permits it. Test a Failed generic status does not falsely become AccessDenied, Capped, or ParseFailed. + +- [ ] **Step 5: Make pure server intake green and commit it separately** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_intake +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm crates/cmtraceopen-parser/tests/sccm_server_intake.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server +git commit -m "feat(sccm): define server intake coverage contract" +~~~ + +## Task 2: Implement #335 native role discovery, bounded capture, and server manifest writing + +**Files:** + +- Modify: src-tauri/Cargo.toml +- Modify: src-tauri/src/sccm/mod.rs +- Extend/reuse: src-tauri/src/sccm/bundle.rs, src-tauri/src/sccm/manifest.rs +- Create: src-tauri/src/sccm/collector/mod.rs +- Create: src-tauri/src/sccm/collector/discovery.rs +- Create: src-tauri/src/sccm/collector/engine.rs +- Create: src-tauri/src/sccm/collector/manifest.rs +- Create: src-tauri/tests/sccm_server_collection.rs +- Create only if a supported command-line collection profile is intentionally shipped: paired scripts/collection/sccm-server-evidence-profile.json and references/collection/sccm-server-evidence-profile.json + +**Consumes:** Task 1 pure catalog/manifest schema plus current native sccm-diagnostics feature/bundle code. Existing ESP discovery can be reused only as a private bounded-path primitive after targeted tests. + +**Produces:** A native library-level server capture adapter which discovers observed/configured roles safely, captures a bounded incident bundle, and writes deterministic SCCM server manifest v1. + +- [ ] **Step 1: Write temporary-directory/native fake-discovery tests first** + +Do not require the real lab to test behavior. Use a fake discovery provider and temp paths to prove: + + - role discovery returns an observed role/configured source candidate without asserting roles that were not observed; + - configured non-default roots are collected when allow-listed; + - current/.lo_/numbered/timestamped rotations map to unique bundle-relative paths; + - two sources sharing a basename cannot overwrite one another; + - file/byte caps produce Capped with a retained partial artifact record and no unsafe success claim; + - access/provider failures produce AccessDenied or documented discovery error state; + - reparse/symlink paths outside the allowed root are rejected; + - results/manifests are deterministic despite concurrent collection; + - server bundle writing does not alter existing generic manifest.json behavior or ESP tests; + - if script profiles are shipped, scripts/ and references/ copies compare byte-for-byte. + +- [ ] **Step 2: Run native test red** + +~~~bash +cargo test --locked -p cmtrace-open --test sccm_server_collection --features sccm-diagnostics +~~~ + +Expected: FAIL because no server collector/test target/module exists. Add feature/test registration only as required to reach a missing-symbol test failure. + +- [ ] **Step 3: Implement an explicit role-discovery boundary** + +Define a testable provider interface or function boundary for read-only role/configuration discovery. It may read safe Windows role/configuration evidence where available, but must return observed facts/candidates with provenance and failure detail. It may not use a default path alone to set an observed role. The engine selects catalogued incident bundles (site core, MP, DP, SUP) and copies only allow-listed files under explicit per-artifact count/byte limits. + +Use a collision-safe layout such as: + +~~~text +evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log +evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log +evidence/sccm/server/distribution-point/server-dp-distribution/numbered-2/distmgr.log.2 +~~~ + +The manifest writer owns role/topology/configuredPath/original basename/rotation/capture state fields. It must not mutate generic ArtifactResult meanings. Preserve original source path only through the approved redaction/provenance field, never in a public unsafe export. + +- [ ] **Step 4: Run regressions and compile gates** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtrace-open --test sccm_server_collection --features sccm-diagnostics +cargo test --locked -p cmtrace-open --test sccm_client_intake --features sccm-diagnostics +cargo test --locked -p cmtrace-open --test esp_diagnostics_sources --all-features +cargo test --locked -p cmtrace-open --test parser_expanded_corpus --all-features +cargo clippy --locked -p cmtrace-open --all-targets --all-features -- -D warnings +git diff --check +~~~ + +- [ ] **Step 5: Commit native server capture separately and add the Windows lab checklist** + +~~~bash +git add src-tauri/Cargo.toml src-tauri/src/sccm src-tauri/tests/sccm_server_collection.rs scripts/collection/sccm-server-evidence-profile.json references/collection/sccm-server-evidence-profile.json +git commit -m "feat(sccm): capture role-aware server evidence" +~~~ + +Only include script/reference files if they were actually added. Create docs/sccm/validation/server-intake-lab-checklist.md in its own documentation commit. It must record server version, site version, observed roles, configured paths, incident bundle chosen, capture limits, time zone, redaction method, and disposal/retention—not credentials or customer identifiers. + +## Task 3: Implement #327 site core and status analysis + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +- Create: crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +- Create fixtures: healthy, component-failure, inbox-backlog, status-processing-failure, recovery, contradictory, rotation-boundary, incomplete + +**Consumes:** #335 server source groups server-sitecomp and server-status, #318 signals/keys/findings, and server topology provenance. + +**Produces:** Role-local site component/status transactions and findings. It may qualify a later MP/DP/SUP observation but cannot diagnose an absent downstream role. + +### State contract + +~~~text +ComponentStart -> ComponentWork -> InboxOrQueue -> StatusOrStateProcessing -> HealthyOrTerminal +~~~ + +The concrete component identity is key/profile data; do not aggregate every site component into one device-wide health result. A backlog means observed pending work, not a root cause. A later successful status record can show recovery only for the same profile-validated component/transaction context. + +- [ ] **Step 1: Write failing #327 fixtures** + +Required outcomes: healthy completion; terminal component failure; inbox/queue backlog as symptom/deferred until terminal evidence; status/state processing failure; same-component recovery; unrelated same-minute component error; a rotation boundary that cannot form a terminal record; and missing site/status coverage. Each asserts last success, class/confidence, exact evidence, and bounded next artifact. + +- [ ] **Step 2: Run target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_site_core +~~~ + +- [ ] **Step 3: Implement source-specific facts and component-keyed reducer** + +Only profile-validated component/status IDs create component transactions. Persist unknown raw signals/evidence as symptoms. Require source-specific terminal facts for ConfirmedFailure; otherwise a backlog/error remains a symptom or likely contributor. Do not give #327 a client request ID or infer a client impact. + +- [ ] **Step 4: Verify and commit** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_site_core +cargo test --locked -p cmtraceopen-parser --test sccm_server_intake +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/server crates/cmtraceopen-parser/tests/sccm_server_site_core.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core +git commit -m "feat(sccm): analyze site core status evidence" +~~~ + +## Task 4: Implement #328 Management Point analysis + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +- Create: crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +- Create fixtures: healthy-policy, auth-failure, registration-failure, location-failure, policy-failure, iis-supplemental, unrelated-client-like-key, rotation-boundary, incomplete + +**Consumes:** #335 MP source groups; #318 request/client/site/policy/host keys; optional #327 site-core result only as an independently cited context fact. + +**Produces:** Server-local MP request/auth/registration/location/policy transactions, ready for but not performing #333 policy-to-MP matching. + +### State contract + +~~~text +ReceiveRequest -> Authenticate -> RegisterOrIdentify -> ResolveLocationOrPolicy -> Respond -> RecordOutcome +~~~ + +The transaction needs an exact profile-validated request/policy/client key and compatible MP topology. A server error near a client timestamp is not an MP transaction. IIS records are supplemental—the main MP implementation cannot require an arbitrary IIS log tree to return a conservative result. + +- [ ] **Step 1: Write red fixture tests** + +Test full healthy policy response; terminal auth failure; terminal registration failure; location failure; policy generation/response failure; missing optional IIS has no failure; a matching-looking but incompatible client ID/key does not attach; partial source coverage returns a precise MP artifact request; rotation physical fragment cannot create an authentication outcome. + +- [ ] **Step 2: Run target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_management_point +~~~ + +- [ ] **Step 3: Implement facts/reducer with no client-cause claim** + +Extract facts separately from MP_GetAuth, MP_CliReg/registration, MP_Location, MP_GetPolicy, mpcontrol, and catalogued supplemental IIS records. Group only exact validated keys. Use bounded role-local findings and surface counterpart-ready keys/evidence refs. If client identity is privacy-classified, use the #318 safe handle in a key only when its correlation rules permit it. + +- [ ] **Step 4: Verify and commit** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_management_point +cargo test --locked -p cmtraceopen-parser --test sccm_server_site_core +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/server crates/cmtraceopen-parser/tests/sccm_server_management_point.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server/management_point +git commit -m "feat(sccm): analyze management point evidence" +~~~ + +Document #328's exact profile/key scope as the contractual handoff to #333; do not add correlation code in this issue. + +## Task 5: Implement #329 Distribution Point/content analysis + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +- Create: crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs +- Create fixtures: healthy-package, distribution-failure, transfer-retry, validation-failure, content-version-mismatch, serve-observed, client-only-looking-request, rotation-boundary, absent-dp, incomplete + +**Consumes:** #335 DP source groups; #318 package/content/version/DP/server keys and signals. + +**Produces:** Role-local package/content distribution/validation/serving analysis, with counterpart-ready exact content/version/DP keys. + +### State contract + +~~~text +ReceiveContent -> Distribute -> Transfer -> Validate -> MakeAvailable -> ServeOrReport +~~~ + +A content package may have multiple versions and multiple DPs. The transaction key must include exact content/package identifier plus version/DP topology when applicable. Do not report a client download failure as a DP failure; that belongs to #333 only if a compatible client-to-DP pair is later proven. + +- [ ] **Step 1: Write failing DP fixture tests** + +Cover healthy package; terminal distribution/transfer/validation failure; retry/backlog; exact same content with mismatching version; observed serving outcome; source coverage absent; unrelated client-style requests; malformed/rotation boundary; and deterministic sorting of multiple DPs/content versions. + +- [ ] **Step 2: Run target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_distribution_point +~~~ + +- [ ] **Step 3: Implement content/version/topology reducer** + +Extract source-local distribution, transfer, provider, pull-DP, and optional serving facts. Key by normalized content/package/version plus DP host only under a validated profile. Preserve retry/backlog as a state/symptom, and require terminal source-specific evidence for failure. If DP role coverage is absent, return an InsufficientEvidence artifact request rather than a role diagnosis. + +- [ ] **Step 4: Verify and commit** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_distribution_point +cargo test --locked -p cmtraceopen-parser --test sccm_server_intake +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/server crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point +git commit -m "feat(sccm): analyze distribution point content evidence" +~~~ + +## Task 6: Implement #330 Software Update Point and WSUS analysis + +**Files:** + +- Create: crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs +- Modify: crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +- Create: crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs +- Create fixtures: sync-success, wcm-configuration-failure, wsus-health-failure, sync-retry, metadata-failure, sup-setup-failure, supplemental-wsus-skipped, unrelated-update-key, rotation-boundary, incomplete + +**Consumes:** #335 SUP/WSUS groups, #318 source/version/key/finding contracts, and optional catalogued WSUS supplemental sources. + +**Produces:** Server-local synchronization/configuration/WSUS health transactions. It does not diagnose a client scan/install path without #333 counterpart evidence. + +### State contract + +~~~text +Configure -> Synchronize -> ImportOrProcessMetadata -> ValidateWsus -> PublishAvailability -> HealthyOrTerminal +~~~ + +ValidateWsus is not presumed merely because WSUSCtrl.log exists. A sync retry is not terminal failure. A client update/KB token cannot attach to a server sync run unless a validated shared key/profile supports it; client/SUP causality remains outside #330. + +- [ ] **Step 1: Add red fixture tests** + +Require success, configuration failure, terminal WSUS health failure, retry/deferred sync, metadata processing failure, SUP setup failure, intentionally skipped supplemental WSUS evidence, unrelated update token, malformed rotation fragment, and incomplete required group scenarios. Assert class/confidence/evidence/next artifact exactly. + +- [ ] **Step 2: Run target red** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_server_software_update_point +~~~ + +- [ ] **Step 3: Implement source-local facts and SUP reducer** + +Use distinct extractors for WCM configuration, sync, WSUS control/health, setup, and catalogued supplemental logs. Reduce by validated sync/run/update metadata keys only. Retain unknown signal codes losslessly. A terminal ConfirmedFailure needs source-specific terminal evidence and sufficient coverage; a skipped/capped supplemental source lowers confidence rather than becoming a failure. + +- [ ] **Step 4: Verify and commit** + +~~~bash +cargo fmt --check --all +cargo test --locked -p cmtraceopen-parser --test sccm_server_software_update_point +cargo test --locked -p cmtraceopen-parser --test sccm_server_distribution_point +cargo test --locked -p cmtraceopen-parser +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +git diff --check + +git add crates/cmtraceopen-parser/src/sccm/server crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point +git commit -m "feat(sccm): analyze software update point evidence" +~~~ + +## Task 7: Run server release gates and lab validation + +**Files:** + +- Create: docs/sccm/validation/server-intake-lab-checklist.md +- Create: docs/sccm/validation/server-core-workflows-lab-checklist.md +- Modify: GitHub issues #335, #327–#330 with exact test/fixture/native validation evidence +- Modify CI workflow files only after a targeted Windows SCCM test job is designed/reviewed + +**Consumes:** All preceding parser/native changes and the development SCCM Server when available. + +**Produces:** An evidence-backed statement of what is parser-proven, native test-double-proven, and Windows-server-proven. + +- [ ] **Step 1: Execute all focused parser suites** + +~~~bash +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo test --locked -p cmtraceopen-parser --test sccm_server_intake +cargo test --locked -p cmtraceopen-parser --test sccm_server_site_core +cargo test --locked -p cmtraceopen-parser --test sccm_server_management_point +cargo test --locked -p cmtraceopen-parser --test sccm_server_distribution_point +cargo test --locked -p cmtraceopen-parser --test sccm_server_software_update_point +cargo test --locked -p cmtraceopen-parser +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo fmt --check --all +git diff --check +~~~ + +- [ ] **Step 2: Execute native regression suites** + +~~~bash +cargo test --locked -p cmtrace-open --test sccm_server_collection --features sccm-diagnostics +cargo test --locked -p cmtrace-open --test sccm_client_intake --features sccm-diagnostics +cargo test --locked -p cmtrace-open --test esp_diagnostics_sources --all-features +cargo test --locked -p cmtrace-open --test parser_expanded_corpus --all-features +cargo clippy --locked -p cmtrace-open --all-targets --all-features -- -D warnings +~~~ + +- [ ] **Step 3: Execute the development-server validation safely** + +Before capture, the checklist requires: confirmed development-only host; ConfigMgr/site version; exact observed server roles; configured/actual paths; chosen synthetic incident; source group selection; capture caps; local time/offset; redaction strategy; secure storage and disposal. Run discovery first, compare observed roles/candidates to catalog, then capture a bounded bundle. Treat any unobserved candidate/source semantic as a validation result, not an automatic coding failure. + +- [ ] **Step 4: Create sanitized fixture deltas only after independent review** + +Never add full lab logs. Extract the minimum synthetic record sequence necessary to recreate an observed parser contract, replace every identifier consistently, retain timestamp/rotation/line relationships, verify redaction, and rerun focused parser tests. If a live finding requires additional server behavior not already catalogued, create a dedicated #334-style source-contract issue rather than widening #327–#330 blindly. + +- [ ] **Step 5: Add Windows acceptance to CI only once source contracts are stable** + +Design a dedicated Windows SCCM collection/contract job analogous to the existing Windows-targeted diagnostics checks. It must test manifest/collision/rotation/provenance behavior using synthetic temp paths; it must not depend on a live lab server or credentials. Native configured-path discovery receives final acceptance on Windows CI plus the dev server, not macOS. + +## Per-Issue Exit Criteria + +### #335 Server intake + +- [ ] Pure source catalog and manifest reader cover multi-role, configured-path, rotation, multiline, absent/access/cap/skipped/unsupported, and deterministic ordering scenarios. +- [ ] Native capture preserves host/role/topology/path/rotation/state/size without changing generic bundle meanings. +- [ ] Filename collisions/reparse escape/legacy mapping/script-profile parity tests pass. +- [ ] Windows Server validation is recorded as passing or pending, with no false live-acceptance claim. + +### #327 Site core + +- [ ] Component/status transactions use validated component context and keep backlog/deferred separate from terminal failure. +- [ ] Healthy/recovery/terminal/contradictory/rotation/incomplete fixtures pass. +- [ ] No client impact/root-cause assertion escapes this role-local analyzer. + +### #328 Management Point + +- [ ] Auth/registration/location/policy phases remain distinct and key/topology-gated. +- [ ] Optional IIS coverage does not force failure; client-looking timestamps/keys cannot create a transaction by proximity. +- [ ] Output exposes exact, cited counterpart-ready evidence for #333 without performing correlation. + +### #329 Distribution Point + +- [ ] Content/package/version/DP topology prevents merges across multiple DPs or versions. +- [ ] Retry/backlog, distribution/validation failure, absence, and rotation boundaries are distinct. +- [ ] Output makes no client download/DP causality claim before #333. + +### #330 SUP/WSUS + +- [ ] Configure/sync/metadata/WSUS validation/publish phases are distinct, with retries/deferred separate from failure. +- [ ] Supplemental WSUS coverage is explicitly optional/capped/skipped and cannot create false terminal health. +- [ ] No client update causal statement is made before a validated future correlation pair. diff --git a/docs/superpowers/plans/2026-08-04-sccm-317-main-integration.md b/docs/superpowers/plans/2026-08-04-sccm-317-main-integration.md new file mode 100644 index 000000000..d9ef1edd4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-sccm-317-main-integration.md @@ -0,0 +1,184 @@ +# SCCM Epic #317 Main Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Integrate the independently accepted SCCM diagnostics candidate with current `origin/main`, preserve both product lines at the two conflicting seams, and produce a newly validated frozen SHA for PR #490. + +**Architecture:** Merge `origin/main` into the dedicated SCCM worktree so the reviewed SCCM history remains inspectable. Keep the parser crate's new private wire module alongside the public SCCM module, and accept main's application-wide elevation replacement by deleting the obsolete ESP-specific relaunch module. Re-run the complete cross-platform gate, compare formatting against main's inherited baseline, and obtain independent review of the new merge SHA before changing PR readiness. + +**Tech Stack:** Git, Rust/Cargo, Tauri v2, TypeScript, GitHub Actions + +--- + +### Task 1: Merge the current protected-branch head + +**Files:** +- Modify: merge index only; do not edit conflict content in this task + +- [ ] **Step 1: Verify the frozen input** + +Run: + +```bash +git merge-base --is-ancestor 112bc4b55166567095db8a76662152ebdc8720f5 HEAD +test -z "$(git status --porcelain)" +``` + +Expected: both commands exit `0` with no output. The accepted SHA remains an ancestor; a documentation-only integration-plan commit may follow it. + +- [ ] **Step 2: Merge current main without committing** + +Run: + +```bash +git merge --no-ff --no-commit origin/main +``` + +Expected: merge stops with conflicts only in `crates/cmtraceopen-parser/src/lib.rs` and `src-tauri/src/esp/relaunch.rs`. + +### Task 2: Preserve both parser crate modules + +**Files:** +- Modify: `crates/cmtraceopen-parser/src/lib.rs` + +- [ ] **Step 1: Resolve the module list** + +Make the final module tail exactly: + +```rust +pub mod intune; +pub mod models; +pub mod parser; +pub mod sccm; +pub(crate) mod wire; +``` + +This preserves the SCCM public API while retaining main's crate-private wire module. + +- [ ] **Step 2: Verify both module trees compile** + +Run: + +```bash +cargo check --locked -p cmtraceopen-parser +``` + +Expected: `Finished` with exit `0`. + +### Task 3: Accept the application-wide elevation owner + +**Files:** +- Delete: `src-tauri/src/esp/relaunch.rs` +- Verify: `src-tauri/src/elevation/relaunch.rs` +- Verify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Resolve the modify/delete conflict by deletion** + +Run: + +```bash +git rm src-tauri/src/esp/relaunch.rs +``` + +Expected: the obsolete ESP-specific relaunch module is staged as deleted. Current main routes elevation through `elevation::relaunch`, so retaining the old module would violate the repository's no-backward-compatibility rule. + +- [ ] **Step 2: Confirm no production reference retains the obsolete owner** + +Run: + +```bash +rg -n "esp::relaunch|mod relaunch" src-tauri/src/esp src-tauri/src/lib.rs +``` + +Expected: no `esp::relaunch` or ESP-local `mod relaunch` reference. + +### Task 4: Commit the integration + +**Files:** +- Modify: Git merge index + +- [ ] **Step 1: Confirm every conflict is resolved** + +Run: + +```bash +test -z "$(git diff --name-only --diff-filter=U)" +git diff --check +``` + +Expected: both commands exit `0` with no unresolved paths or whitespace errors. + +- [ ] **Step 2: Create the merge commit** + +Run: + +```bash +git commit -m "merge: integrate current main into SCCM diagnostics" +``` + +Expected: one merge commit with parents `112bc4b5` and current `origin/main`. + +### Task 5: Re-run the frozen-candidate gate + +**Files:** +- Test: workspace and parser targets only; do not change code to mask failures + +- [ ] **Step 1: Run the complete native workspace suite** + +Run: + +```bash +cargo test --locked --workspace --all-targets --quiet +``` + +Expected: exit `0`, including benches compiled as test targets. + +- [ ] **Step 2: Run portability and lint gates** + +Run: + +```bash +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +``` + +Expected: both commands exit `0`. + +- [ ] **Step 3: Run frontend and repository hygiene gates** + +Run: + +```bash +npx tsc --noEmit +rustfmt --edition 2021 --check --config skip_children=true crates/cmtraceopen-parser/src/lib.rs +git diff --check +test -z "$(git status --porcelain)" +``` + +Expected: every command exits `0` and the worktree remains clean. A detached `origin/main` comparison establishes that repo-wide `cargo fmt --all -- --check` already fails on inherited Jamf, Intune, ESP, and elevation files; do not churn those unrelated files in this integration. + +### Task 6: Freeze, review, and publish the successor + +**Files:** +- Verify: PR #490 head and evidence pack + +- [ ] **Step 1: Record the successor SHA and obtain independent review** + +Run: + +```bash +git rev-parse HEAD +git show --no-patch --format='%H %P %s' HEAD +``` + +Expected: a clean merge SHA with exactly two parents. The critic inspects this SHA, the two conflict resolutions, and the full gate results, returning `ACCEPT` or specific rework. + +- [ ] **Step 2: Publish without rewriting history** + +Run: + +```bash +git push origin codex/sccm333-integration-timestamp-gate +``` + +Expected: fast-forward update of PR #490. Keep the PR draft until authorized Windows SCCM lab evidence passes. diff --git a/docs/superpowers/plans/2026-08-04-sccm-321-policy-production.md b/docs/superpowers/plans/2026-08-04-sccm-321-policy-production.md new file mode 100644 index 000000000..18f6e785c --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-sccm-321-policy-production.md @@ -0,0 +1,71 @@ +# SCCM Client Policy Production Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the #321 client policy/assignment analyzer as a deterministic production reducer over the accepted #318/#319 intake, admission, key, and finding authority. + +**Architecture:** A new `sccm::client::policy` reducer owns policy workflow semantics but accepts only the public canonical bundle, its reassessed intake projection, and bytes-only admission payloads. The existing admission capability remains the sole source of normalized CCM evidence and extraction profiles; the reducer groups only exact assignment/policy keys, treats time as ordering evidence rather than identity, and emits shared validated findings plus explicit coverage/profile gaps. + +**Tech Stack:** Rust, serde, existing CCM scanner, SCCM client intake/admission, shared SCCM key extraction, shared SCCM finding builder, SHA-256 fixture oracles. + +--- + +### Task 1: Register policy key authority + +**Files:** +- Modify: `crates/cmtraceopen-parser/src/sccm/models.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/keys.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/findings.rs` +- Test: `crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs` + +- [ ] Add `PolicyId` to `SccmCorrelationKeyKind` and register `policy-client-5.00.test-v1` as the stable `ClientPolicy` profile for canonical version `5.00.TEST.0000`. +- [ ] Make stable policy extraction emit exact `AssignmentId`, `PolicyId`, `RequestId`, `StateMessageId`, and `SiteCode` keys with their admitted evidence references; a caller-assembled profile with the same label must still fail the built-in profile-shape check. +- [ ] Register only that canonical profile with finding validation and update exhaustive key ordering. +- [ ] Run the SCCM spine and client authority tests; expected result is all green with stable policy keys exact and non-policy behavior unchanged. + +### Task 2: Add the sealed policy reducer + +**Files:** +- Create: `crates/cmtraceopen-parser/src/sccm/client/policy.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/mod.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/client/admission.rs` + +- [ ] Export `analyze_client_policy(bundle, assessment, payloads) -> Result`; do not accept normalized evidence, profiles, keys, or findings from callers. +- [ ] Call `admit_client_evidence` before reduction and use only its sealed evidence/key accessors for facts. Permit sealed non-comparable timestamp provenance to remain evidence, but never use it for causal ordering. +- [ ] Parse the closed phases `Request`, `Download`, `TransferAuth`, `Persist`, `Schedule`, `Evaluate`, and `Report` only from admitted CCM records containing one exact assignment/policy pair. Treat request IDs as optional transaction metadata and never synthesize unresolved values. +- [ ] Reduce exact-key facts deterministically: later comparable same-phase success may recover an earlier failure; equal/non-comparable opposing outcomes are contradictory; phase inversion fails closed; time alone never joins records. +- [ ] Emit last confirmed phase, exact terminal evidence, bounded next artifacts, explicit absent/capped/profile gaps, and collision-resistant observation identities containing artifact and physical line provenance. +- [ ] Use `SccmFindingBuilder` for every finding. Confirmed failures include `SccmTerminalEvidence::observed_failure`; incomplete findings include shared coverage gaps and requests; successful cycles emit no finding. + +### Task 3: Drive the complete preparation corpus through production + +**Files:** +- Create: `crates/cmtraceopen-parser/tests/sccm_client_policy.rs` +- Modify: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/*/manifest.json` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json` + +- [ ] Build canonical intake bundles from every committed policy preparation manifest, bind each retained complete payload to its exact byte length and SHA-256, assess it, and invoke the exported analyzer. +- [ ] Compare the complete serialized production output for every fixture to `production-oracles.json`; also reverse artifact and payload order and require byte-for-byte identical JSON. +- [ ] Assert the acceptance states: completed cycle, request/transfer authentication failure, download/persist/evaluate/report terminal failures, scheduler deferral, recovery, missing policy-state coverage, contradictory outcomes/offsets, multiline framing, rotation split, and unvalidated malformed input. +- [ ] Add focused no-assignment, stale-assignment, and corrupt-processing coverage without expanding into application enforcement or update-install outcomes. + +### Task 4: Add adversarial authority gates + +**Files:** +- Test: `crates/cmtraceopen-parser/tests/sccm_client_policy.rs` + +- [ ] Mutate a canonical post-intake assessment, payload digest/bytes, profile version, evidence ordering, exact keys, and physical line identity; each authority mutation must fail closed or remain source-local. +- [ ] Prove two exact-key transactions at the same instant remain separate and two observations on the same artifact/line cannot collide. +- [ ] Prove unkeyed records at matching timestamps never enter a transaction and public JSON contains neither raw paths nor client/management-point handles. + +### Task 5: Verify and freeze + +**Files:** +- Verify only the issue-scoped files above. + +- [ ] Run the focused policy suite and the committed policy preparation contract. +- [ ] Run client intake, admission, authority/spine, and full parser tests. +- [ ] Run `cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown`. +- [ ] Run `cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings`. +- [ ] Run scoped `rustfmt --check`, JSON validation, and `git diff --check`. +- [ ] Commit exactly one clean issue-scoped production slice and return its frozen SHA and evidence pack without claiming acceptance. diff --git a/docs/superpowers/plans/2026-08-04-sccm-333-production-correlation.md b/docs/superpowers/plans/2026-08-04-sccm-333-production-correlation.md new file mode 100644 index 000000000..6e8e04550 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-sccm-333-production-correlation.md @@ -0,0 +1,121 @@ +# SCCM Production Correlation Implementation Plan + +> **For agentic workers:** Execute this plan inline. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Correlate exactly the policy↔management-point, content↔distribution-point, and updates↔software-update-point pairs without weakening any source-local result. + +**Architecture:** Add one public `sccm::correlation` module. Pair adapters translate only accepted public endpoint facts and bounded coverage/profile metadata into one private canonical reducer; the reducer owns all shared guards, deterministic ordering, hashed public handles, reason codes, and artifact requests. The source analyses are borrowed and never mutated or reserialized by the reducer. + +**Tech Stack:** Rust, Serde, SHA-256 via the existing `sha2` dependency, JSON fixture oracles, Cargo test/Clippy/rustfmt. + +--- + +### Task 1: Freeze the production contract + +**Files:** +- Create: `crates/cmtraceopen-parser/src/sccm/correlation.rs` +- Modify: `crates/cmtraceopen-parser/src/sccm/mod.rs` + +- [x] **Step 1: Add public pair/result types and private canonical facts** + +Define `SccmCorrelationPair`, `SccmCorrelationOutcome`, `SccmCorrelationLinkStrength`, `SccmCorrelationConfidence`, `SccmCorrelationGuard`, `SccmCorrelationReason`, `SccmCorrelationArtifactRequest`, `SccmCorrelationResult`, and `SccmCorrelationAnalysis`. All serialized enums use camelCase; collections are sorted and bounded. + +- [x] **Step 2: Add three typed pair inputs** + +Expose private-field input structs created only by `from_analyses` adapters: + +```rust +pub struct SccmPolicyManagementPointInput { canonical: CanonicalInput } +pub struct SccmContentDistributionPointInput { canonical: CanonicalInput } +pub struct SccmUpdatesSoftwareUpdatePointInput { canonical: CanonicalInput } +``` + +Each adapter reads accepted public counterpart facts/transactions plus profile, coverage, and rotation state; it does not parse raw records or accept caller-provided identities. + +- [x] **Step 3: Export the module** + +Add `pub mod correlation;` and `pub use correlation::*;` through `sccm/mod.rs`. + +### Task 2: Implement pair translation and the shared reducer + +**Files:** +- Modify: `crates/cmtraceopen-parser/src/sccm/correlation.rs` + +- [x] **Step 1: Translate policy and management-point facts** + +Use request ID + policy ID as the exact key and site code as shared topology. Admit only the accepted client and server profile IDs. Use normalized observation timestamps and server terminal evidence; carry only bounded logical artifact requests. + +- [x] **Step 2: Translate content and distribution-point facts** + +Use package ID + content ID + content version as the exact key and the opaque DP handle as topology. Use the client counterpart-ready request fact and the server transaction's terminal observation; reject content-version conflicts. + +- [x] **Step 3: Translate updates and software-update-point facts** + +Use update ID as the exact cross-side key and site code + opaque SUP handle as topology. Use the client counterpart-ready location fact and the server transaction's terminal observation. + +- [x] **Step 4: Enforce the shared gates** + +The reducer checks all 13 registered guards for every pair. `ExactCorroborated` + `High` is possible only with accepted profiles, one compatible exact key, compatible topology, normalized comparable ordering, complete coverage/rotation, and a matching terminal server failure. Every other state returns conservative strength/confidence, reason codes, and side-owned requests. + +- [x] **Step 5: Make output deterministic and private** + +Sort/deduplicate facts and requests before reduction. Compute result IDs and fact handles from canonical SHA-256 preimages. Do not serialize raw keys, paths, hostnames, users, tokens, evidence messages, or unapproved identifiers. + +### Task 3: Promote the registry and matrices to production oracles + +**Files:** +- Modify: `crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/pair-registry.json` +- Modify: `crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/shared/adversarial-matrix.json` +- Modify: `crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/policy_management_point/adversarial-matrix.json` +- Modify: `crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/content_distribution_point/adversarial-matrix.json` +- Create: `crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/updates_software_update_point/adversarial-matrix.json` + +- [x] **Step 1: Mark exactly three pairs production enabled** + +Set every pair to `ruleValidated`, `productionEnabled: true`, `ruleValidated: true`, implementation module `sccm::correlation`, all 13 guards, and no blockers. + +- [x] **Step 2: Add exact expected serialized outputs and hashes** + +Each pair matrix contains a healthy exact case plus one executable construction for every guard, including opposite-order A/B cases with identical expected output. Pin the complete JSON output and its SHA-256 hash. + +- [x] **Step 3: Apply every shared guard to every pair** + +Update the shared matrix `appliesTo` arrays to include all three workflows and retain closed required-output obligations. + +### Task 4: Replace scaffolding tests with production tests + +**Files:** +- Modify: `crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs` + +- [x] **Step 1: Execute all three matrices through the public reducers** + +Build typed endpoint analyses/facts, run each pair adapter and reducer, compare the entire serialized `SccmCorrelationAnalysis`, then hash those exact bytes and compare the pinned digest. + +- [x] **Step 2: Add mutation gates** + +Mutate each guard input and assert it cannot remain exact/high. Add duplicate/collision, contradictory recovery, malformed expected projection/hash, and missing-counterpart request tests. + +- [x] **Step 3: Add ordering and privacy gates** + +Reverse and duplicate input facts and assert byte-identical output and IDs. Seed raw path/host/user/token markers in source-local fields and assert none appears in correlation JSON. + +- [x] **Step 4: Prove source-local immutability** + +Serialize both source analyses before input construction and correlation, then assert the bytes remain identical afterward for all three pairs. + +### Task 5: Validate and freeze + +**Files:** +- Test: all touched correlation and upstream suites + +- [x] **Step 1: Run focused tests** + +Run `cargo test --locked -p cmtraceopen-parser --test sccm_correlation_contract` and the six upstream endpoint suites. + +- [x] **Step 2: Run package and target gates** + +Run full parser tests, wasm32 check, and strict all-target Clippy. + +- [x] **Step 3: Run hygiene gates and commit** + +Run scoped rustfmt, `jq empty` on all correlation JSON, `git diff --check`, inspect the issue-only diff, commit, and verify a clean worktree at the frozen SHA. diff --git a/docs/superpowers/plans/2026-08-04-sccm-native-product-path-rework.md b/docs/superpowers/plans/2026-08-04-sccm-native-product-path-rework.md new file mode 100644 index 000000000..2b8eee48d --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-sccm-native-product-path-rework.md @@ -0,0 +1,294 @@ +# SCCM Native Product Path Rework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship SCCM diagnostics as an executable Windows product path that discovers installed client/server roles, creates bounded privacy-safe bundles, and exposes coverage outcomes in a dedicated application workspace. + +**Architecture:** Keep semantic analysis in `cmtraceopen-parser`; add a feature-gated native collector under `src-tauri/src/sccm/collector` with an injectable discovery provider and one collision-safe capture engine. Tauri commands expose only discovery and capture summaries—never raw registry data, hostnames, site codes, or source paths—and the React workspace presents the returned roles, source states, and retained bundle location. + +**Tech Stack:** Rust 1.88+, Tauri v2, `winreg`, Windows PowerShell/CIM with a fixed read-only query, serde/serde_json, React 19, TypeScript, Zustand, Fluent UI + +--- + +## File structure + +- `src-tauri/src/sccm/collector/mod.rs`: public collector entry points and shared request/result wire types. +- `src-tauri/src/sccm/collector/discovery.rs`: injectable discovery provider plus the Windows registry/CIM implementation. +- `src-tauri/src/sccm/collector/engine.rs`: allow-listed enumeration, rotation classification, bounds, no-overwrite copy, and coverage rows. +- `src-tauri/src/sccm/collector/client_manifest.rs`: existing schema-v1 client manifest construction and validation. +- `src-tauri/src/sccm/collector/server_manifest.rs`: canonical server manifest JSON construction and parser-side validation. +- `src-tauri/src/commands/sccm.rs`: Tauri command boundary and app-cache destination selection. +- `src-tauri/tests/sccm_native_collection.rs`: fake-provider discovery/capture contract suite. +- `src/workspaces/sccm/`: one Windows workspace, store, wire types, styles, and component tests. + +### Task 1: Put SCCM diagnostics in the shipped feature graph + +**Files:** +- Modify: `src-tauri/Cargo.toml` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src-tauri/src/commands/app_config.rs` + +- [ ] **Step 1: Write availability and registration tests** + +Add assertions that a default/full build contains `sccm`, and that the invoke handler contains both SCCM commands: + +```rust +#[test] +fn sccm_workspace_availability_matches_the_build_feature() { + assert_eq!( + get_available_workspaces().contains(&"sccm"), + cfg!(feature = "sccm-diagnostics") + ); +} +``` + +- [ ] **Step 2: Run the focused test red** + +Run: `cargo test --locked -p cmtrace-open commands::app_config::tests::sccm_workspace_availability_matches_the_build_feature --features sccm-diagnostics` + +Expected: FAIL because `sccm` is not returned or registered. + +- [ ] **Step 3: Wire the feature and command module** + +Set `full` to include `sccm-diagnostics`, declare `commands::sccm`, and register these commands behind the same feature: + +```rust +commands::sccm::discover_sccm_environment, +commands::sccm::capture_sccm_diagnostics, +``` + +- [ ] **Step 4: Re-run the focused test** + +Run: `cargo test --locked -p cmtrace-open commands::app_config::tests::sccm_workspace_availability_matches_the_build_feature --features sccm-diagnostics` + +Expected: PASS. + +### Task 2: Define the privacy-safe native command contract + +**Files:** +- Create: `src-tauri/src/sccm/collector/mod.rs` +- Modify: `src-tauri/src/sccm/mod.rs` +- Test: `src-tauri/tests/sccm_native_collection.rs` + +- [ ] **Step 1: Write serialization tests for the public result** + +Use these wire types and verify serialized JSON contains no raw discovery facts: + +```rust +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmEnvironmentDiscovery { + pub supported: bool, + pub configmgr_version: Option, + pub roles: Vec, + pub sources: Vec, + pub issues: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmCaptureResult { + pub bundle_root: String, + pub captured_at_utc: String, + pub roles: Vec, + pub sources: Vec, + pub artifact_count: usize, + pub retained_bytes: u64, +} +``` + +`SccmDetectedRole` carries only `role` and an enum discovery basis; `SccmSourceStatus` carries role, source ID, rotation category, coverage state, retained bytes, and optional generic detail code. Raw host, site code, registry values, source roots, and source filenames outside the allow-listed catalog are private collector fields. + +- [ ] **Step 2: Run the new target red** + +Run: `cargo test --locked -p cmtrace-open --test sccm_native_collection --features sccm-diagnostics` + +Expected: FAIL on missing collector types. + +- [ ] **Step 3: Implement the wire contract and deterministic sorting** + +Sort roles by canonical serialized role name and source rows by `(role, source_id, rotation, state)`. Serialize a fixture containing sentinel host/path/site values and assert none are present. + +- [ ] **Step 4: Run the contract test green** + +Run: `cargo test --locked -p cmtrace-open --test sccm_native_collection public_result --features sccm-diagnostics` + +Expected: PASS. + +### Task 3: Implement read-only Windows role and root discovery + +**Files:** +- Create: `src-tauri/src/sccm/collector/discovery.rs` +- Test: `src-tauri/tests/sccm_native_collection.rs` + +- [ ] **Step 1: Write fake-provider discovery tests** + +Define the test seam: + +```rust +pub(crate) trait SccmDiscoveryProvider { + fn discover(&self) -> Result; +} +``` + +Tests must prove: client service/registry evidence produces only `Client`; each explicit server-role fact produces only its corresponding role; default folders alone never produce roles; denied registry/CIM reads become an issue; duplicate roots collapse by canonical identity; and output order is stable. + +- [ ] **Step 2: Run discovery tests red** + +Run: `cargo test --locked -p cmtrace-open --test sccm_native_collection discovery_ --features sccm-diagnostics` + +Expected: FAIL on the missing provider. + +- [ ] **Step 3: Implement the Windows provider** + +On Windows, read only allow-listed ConfigMgr registry keys and run a fixed non-interactive PowerShell/CIM query when server-role facts require it. Do not interpolate user input. Use registry/service facts to prove the client and site-system roles; use defaults only to add candidate roots after a role is observed. Keep raw site code, host, paths, and CIM payload private and derive only HMAC/SHA-256 opaque handles for manifests. + +On non-Windows platforms return `supported: false` with the generic `unsupportedPlatform` issue. + +- [ ] **Step 4: Run cross-platform and Windows compile gates** + +Run: + +```bash +cargo test --locked -p cmtrace-open --test sccm_native_collection discovery_ --features sccm-diagnostics +cargo check --locked -p cmtrace-open --features sccm-diagnostics +``` + +Expected: PASS locally; the Windows implementation is exercised by hosted Windows CI. + +### Task 4: Implement one bounded collision-safe capture engine + +**Files:** +- Create: `src-tauri/src/sccm/collector/engine.rs` +- Create: `src-tauri/src/sccm/collector/client_manifest.rs` +- Create: `src-tauri/src/sccm/collector/server_manifest.rs` +- Test: `src-tauri/tests/sccm_native_collection.rs` + +- [ ] **Step 1: Write capture-engine failures first** + +Fake-root tests must cover current, `.lo_`, numbered, and timestamped rotations; absent/access-denied/capped/skipped/unsupported rows; malformed rotation names; per-source file and byte caps; symlink/reparse escape; duplicate destination preflight; pre-existing destination no-overwrite; deterministic output; and two roots with the same basename remaining distinct. + +- [ ] **Step 2: Run the capture matrix red** + +Run: `cargo test --locked -p cmtrace-open --test sccm_native_collection capture_ --features sccm-diagnostics` + +Expected: FAIL on the missing engine. + +- [ ] **Step 3: Implement bounded enumeration and copying** + +Use fixed production caps of 8 fragments and 16 MiB per logical source. Canonicalize the approved root, reject any candidate outside it, reject reparse/symlink files, preflight all bundle-relative destinations, then create each destination with create-new semantics. Hash and count the exact retained bytes; a truncated prefix is `Capped`, never `Captured`. + +- [ ] **Step 4: Write and validate both manifests** + +Client capture writes `sccm-manifest.json` using `SccmBundleManifestV1`, then reopens it with `read_sccm_client_intake_bundle`. Server capture writes `sccm-server-manifest.json` with `bundleRole: "server"`, opaque topology handles, canonical role/source/rotation provenance, and no raw paths, then validates the JSON and payloads with `normalize_server_bundle` before returning success. + +- [ ] **Step 5: Run the full native target** + +Run: `cargo test --locked -p cmtrace-open --test sccm_native_collection --features sccm-diagnostics` + +Expected: PASS. + +### Task 5: Expose discovery and capture through Tauri + +**Files:** +- Create: `src-tauri/src/commands/sccm.rs` +- Modify: `src-tauri/src/lib.rs` +- Test: `src-tauri/src/commands/sccm.rs` + +- [ ] **Step 1: Write command-level tests** + +Test the non-Tauri implementation functions with a fake provider and temporary app-cache root. Assert discovery performs no writes; capture creates a UUID-named private bundle below the supplied cache root; and command errors contain only generic codes/details. + +- [ ] **Step 2: Implement commands** + +```rust +#[tauri::command] +pub fn discover_sccm_environment() -> Result; + +#[tauri::command] +pub fn capture_sccm_diagnostics( + app: tauri::AppHandle, +) -> Result; +``` + +The capture command selects the app cache directory itself. It accepts no source path, role claim, host, site, or cap from the frontend. + +- [ ] **Step 3: Run command and registration tests** + +Run: `cargo test --locked -p cmtrace-open commands::sccm --features sccm-diagnostics` + +Expected: PASS. + +### Task 6: Add the Windows SCCM workspace + +**Files:** +- Create: `src/workspaces/sccm/index.ts` +- Create: `src/workspaces/sccm/types.ts` +- Create: `src/workspaces/sccm/sccm-store.ts` +- Create: `src/workspaces/sccm/SccmWorkspace.tsx` +- Create: `src/workspaces/sccm/sccm-workspace.css` +- Create: `src/workspaces/sccm/SccmWorkspace.test.tsx` +- Modify: `src/workspaces/registry.ts` +- Modify: `src/workspaces/registry.test.ts` +- Modify: `src/types/log.ts` +- Modify: `src/lib/commands.ts` + +- [ ] **Step 1: Write registry, store, and component tests** + +Assert `sccm` is Windows-only; initial render offers read-only discovery; discovered roles and every source state render; capture is disabled during work; errors preserve the previous discovery; and a successful capture displays retained artifact/byte counts plus a reveal action. + +- [ ] **Step 2: Run the frontend tests red** + +Run: `npx vitest run src/workspaces/registry.test.ts src/workspaces/sccm/SccmWorkspace.test.tsx` + +Expected: FAIL because the workspace does not exist. + +- [ ] **Step 3: Implement the workspace** + +Use an industrial/utilitarian evidence-console treatment within existing Fluent tokens: a narrow status header, role chips, one primary `Capture diagnostic bundle` action, and a dense source ledger with columns Source, Role, Rotation, State, and Retained. Access-denied/capped/malformed/unsupported states must remain text labels with icons/colors as secondary cues, not color-only signals. + +- [ ] **Step 4: Run frontend gates** + +Run: + +```bash +npx vitest run src/workspaces/registry.test.ts src/workspaces/sccm/SccmWorkspace.test.tsx +npx tsc --noEmit +``` + +Expected: PASS. + +### Task 7: Freeze, review, publish, and repeat the lab + +**Files:** +- Modify: `.github/workflows/ci.yml` only if the existing Windows all-target gate does not exercise the new feature target +- Modify: PR #490 evidence comment + +- [ ] **Step 1: Run the complete local gate** + +Run: + +```bash +cargo test --locked --workspace --all-targets --quiet +cargo clippy --locked -p cmtrace-open --all-targets --all-features -- -D warnings +cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings +cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown +npx vitest run src/workspaces/registry.test.ts src/workspaces/sccm/SccmWorkspace.test.tsx +npx tsc --noEmit +git diff --check +``` + +Expected: PASS, with only the documented inherited repo-wide formatting baseline excluded. + +- [ ] **Step 2: Obtain independent review on a frozen SHA** + +The critic receives the exact SHA, file list, native/frontend test commands, privacy sentinel test, and reproduction steps. Rework until the verdict is `ACCEPT`. + +- [ ] **Step 3: Publish and wait for hosted Windows artifacts** + +Push PR #490, require every hosted job to pass, and verify the new Windows provenance file names the frozen SHA. + +- [ ] **Step 4: Repeat the authorized lab matrix** + +The lab must install the new artifact, invoke the SCCM workspace, exercise discovery/capture for every installed role, and post `SCCM-LAB-RESULT: PASS` or `REWORK`. Merge remains prohibited until PASS is independently reviewed. diff --git a/docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md b/docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md new file mode 100644 index 000000000..a7a1d8c8c --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md @@ -0,0 +1,577 @@ +# Parser Family Architecture and Format Roadmap + +## Status + +Approved overall architecture; the SCCM end-to-end diagnostics expansion is +revised and ready for written review. This document defines the public module +skeleton, compatibility policy, and issue boundaries for the next generation +of `cmtraceopen-parser`. + +The SCCM section is intentionally a diagnostic roadmap, not a claim that the +crate already interprets all Configuration Manager logs. Its proposed tracker +issues are organized around evidence-backed workflows that can eventually power +dedicated SCCM Client and SCCM Server workspaces. + +## Goal + +Replace the implementation-oriented public `parser::*` surface with a +discoverable, product-oriented API. A consumer should be able to find a parser +from its management workload, operating system, product, and artifact type +without knowing the current source-file layout. + +The hierarchy is deliberately: + +```text +:::::: +``` + +The parser crate remains pure Rust and wasm-compatible. Filesystem access, +live Windows event-log access, and platform command execution remain in native +adapters in `src-tauri`. + +## Current-state constraints + +- The published crate currently exposes `collector`, `dsregcmd`, `error_db`, + `esp`, `intune`, `models`, and an implementation-centric `parser` module. +- The parser dispatcher currently owns 20 detected `ParserKind` variants. +- CCM is a reusable record grammar, not an SCCM-only product parser. It is + also used by the Intune Management Extension (IME). +- The existing ESP engine already covers both Enrollment Status Page and + Device Preparation scenarios. +- DNS Audit EVTX and Windows Intune event-log parsing are native-only today; + their pure model/reduction logic can move, but their file readers must not + silently become unconditional crate dependencies. + +## Canonical public tree + +`[current]` means existing behavior moves or is re-exported. `[planned]` +means the path is reserved but does not promise a parser until its issue has +fixtures and an input contract. `[native]` is an existing native adapter that +must remain explicitly feature-gated or outside the pure crate. + +```text +cmtraceopen_parser +├── core [current] +│ ├── types # LogEntry, ParseResult, filters, selection metadata +│ ├── severity +│ ├── encoding +│ └── errors # error lookup, search, and spans +├── detect [current] +├── evidence [current] # profiles/contracts; no on-device I/O +│ +├── ccm [current] # shared CMTrace/CCM grammar +│ ├── records +│ └── legacy # $$< legacy format +├── cmtlog [current] +├── generic [current] +│ ├── timestamped +│ └── plain +│ +├── sccm # semantic SCCM diagnostics over CCM records +│ ├── common [planned shared diagnostic contract] +│ │ ├── artifacts # source catalog, paths, rotation and capture coverage +│ │ ├── evidence # cited normalized records and typed signals +│ │ ├── identifiers # stable correlation keys +│ │ ├── timeline +│ │ └── findings # symptom, diagnosis, confidence, and next evidence +│ ├── client +│ │ └── windows +│ │ ├── intake [planned] +│ │ ├── setup_and_health [planned] +│ │ ├── identity_and_location [planned] +│ │ ├── policy [planned] +│ │ ├── content [planned] +│ │ ├── applications [planned] +│ │ ├── software_updates [planned] +│ │ ├── inventory_and_compliance [planned] +│ │ ├── task_sequence [planned] +│ │ ├── status [planned] +│ │ └── co_management [planned] +│ ├── server +│ │ └── windows +│ │ ├── site_core [planned] +│ │ ├── management_point [planned] +│ │ ├── distribution_point [planned] +│ │ ├── software_update_point [planned] +│ │ ├── hierarchy_and_replication [planned] +│ │ ├── provider_and_admin_service [planned] +│ │ ├── os_deployment [planned] +│ │ ├── notification [planned] +│ │ ├── cloud_and_service_connection [planned] +│ │ ├── reporting [planned] +│ │ └── certificate_enrollment [planned] +│ └── correlation +│ └── client_server [planned] +│ +├── intune +│ ├── apps +│ │ ├── windows +│ │ │ ├── ime [current] +│ │ │ │ ├── logs +│ │ │ │ ├── events +│ │ │ │ ├── policies +│ │ │ │ ├── downloads +│ │ │ │ └── timeline +│ │ │ ├── win32 [planned] +│ │ │ ├── microsoft_store [planned] +│ │ │ ├── scripts [planned] +│ │ │ └── remediations [planned] +│ │ ├── macos +│ │ │ ├── pkg [planned] +│ │ │ └── shell_scripts [planned] +│ │ ├── ios_ipados [planned] +│ │ └── android [planned] +│ │ +│ ├── enrollment +│ │ ├── windows +│ │ │ ├── esp [current] +│ │ │ ├── device_preparation [current through esp] +│ │ │ └── autopilot [planned] +│ │ ├── macos +│ │ │ └── automated_device_enrollment [planned] +│ │ ├── ios_ipados +│ │ │ └── automated_device_enrollment [planned] +│ │ └── android +│ │ ├── work_profile [planned] +│ │ ├── fully_managed [planned] +│ │ └── dedicated [planned] +│ │ +│ ├── device +│ │ ├── windows +│ │ │ ├── configuration [planned] +│ │ │ ├── compliance [planned] +│ │ │ ├── updates [planned] +│ │ │ └── event_log [native] +│ │ └── macos +│ │ └── mdm_daemon [current] +│ │ +│ └── portal # a cross-workload client, not merely an app +│ ├── windows +│ │ └── company_portal +│ │ ├── logs [current collection; planned parser] +│ │ ├── diagnostics [current collection] +│ │ └── package_state [current collection] +│ ├── macos +│ │ └── company_portal +│ │ ├── logs [current discovery; planned parser] +│ │ ├── diagnostics [current import path] +│ │ └── unified_log [current source; planned parser] +│ ├── android +│ │ └── company_portal +│ │ └── diagnostics [planned imported artifact] +│ └── ios_ipados +│ └── company_portal +│ └── diagnostics [planned Console/imported artifact] +│ +├── patchmypc +│ ├── detection [current] +│ ├── ccm [planned facade] +│ └── installers [planned facade] +├── psadt +│ ├── legacy [current] +│ └── ccm [planned facade] +├── installers +│ ├── msi [current] +│ └── burn [current] +├── windows +│ ├── servicing::{cbs, dism} [current] +│ ├── setup::panther [current] +│ ├── update::reporting_events [current] +│ ├── registry [current] +│ └── secure_boot::certificate_update [current] +├── network +│ ├── dhcp [current] +│ └── dns::{debug, types, audit} [audit is native] +├── web::iis::w3c [current] +└── identity::windows::dsregcmd [current] +``` + +## Design decisions + +### CCM and SCCM are separate concepts + +`ccm` owns the raw CMTrace-compatible record grammar. It remains reusable by +SCCM, IME, and any other producer of that wire format. SCCM paths must not +duplicate the raw parser or advertise a distinct ParserKind merely because a +file uses CMTrace syntax. + +Instead, `sccm` owns source classification, normalization, correlation, and +findings. Today, any SCCM CMTrace log resolves to the generic CCM grammar; the +planned SCCM paths become meaningful only as each workflow gains a source +contract, fixtures, semantic analyzer, and evidence-backed output. + +### Intune is workload-first + +IME is one current leaf at `intune::apps::windows::ime`; it is not the Intune +namespace itself. Existing IME event, policy, download, GUID, and timeline +analysis move below that leaf. Existing ESP and Device Preparation logic moves +to `intune::enrollment::windows::esp`. + +### Company Portal is a first-class cross-workload surface + +Company Portal spans sign-in, enrollment, app catalog, compliance, and device +self-service. It therefore belongs at `intune::portal`, alongside—not below— +`apps`, `enrollment`, and `device`. + +Current evidence is platform-specific: + +- Windows Company Portal files are already collected from + `%LOCALAPPDATA%\\Packages\\Microsoft.CompanyPortal_8wekyb3d8bbwe\\LocalState\\*`. +- macOS Company Portal files are already discovered under + `~/Library/Logs/CompanyPortal/`; diagnostic reports and unified-log evidence + are separate input shapes. +- Android diagnostics are user-saved or uploaded artifacts, normally from the + work profile. +- iOS/iPadOS diagnostics are imported captures, including macOS Console + output; the crate must not assume device filesystem access. + +Each dedicated parser needs representative, sanitized fixtures before its +public API becomes non-experimental. + +### Preserve compatibility deliberately + +The first skeleton release adds canonical paths and re-exports existing +implementations. It does not change parsing behavior or delete source files. + +For at least one minor release: + +- `parser` remains as a deprecated compatibility façade. +- `models` remains as a deprecated façade to `core::types`. +- `error_db` remains as a deprecated façade to `core::errors`. +- top-level `esp` remains as a deprecated façade to + `intune::enrollment::windows::esp`. + +The root crate documentation becomes the primary docs.rs landing page: a short +quick start, the family map, stability policy, and links to product modules. + +## SCCM end-to-end diagnostic architecture + +### Product boundary + +The SCCM feature is not a set of independently rendered log files. It turns a +bundle of supplied client and/or server artifacts into a bounded answer to +“what is wrong in this deployment or site workflow?” + +The pure parser crate receives artifact contents and provenance. Native +adapters and the later workspaces discover and collect files, registry exports, +event logs, and optional database/status exports. Neither layer may infer that +a missing file proves success, absence of a role, or absence of a failure. + +The intended dependency direction is: + + raw CCM records -> classified SCCM evidence -> transactions and timeline + -> findings with cited evidence -> SCCM Client / SCCM Server workspace + +The client and server products consume the same contract but never blend their +local state. A client-only bundle can make a client finding and request the +server artifact needed to raise confidence. A server-only bundle can make a +role finding and request the client transaction that would connect it to an +endpoint symptom. + +### Diagnostic contract + +Every SCCM analyzer emits a common, serializable diagnostic model. This is +informed by the existing ESP evidence/coverage/finding model, but must remain +SCCM-specific rather than coupling SCCM behavior to ESP. + +| Contract | Required meaning | +| --- | --- | +| SccmArtifact | A supplied file or export with a stable artifact ID, original path/name, role candidate, collection time, encoding, rotation lineage, and coverage status. The log artifact name is distinct from CCM's source-code-file field. | +| SccmEvidence | A normalized record or imported status fact with an exact artifact/entry reference, timestamp, component, message, raw typed signals, and privacy-classified execution or user context. | +| SccmCorrelationKey | Stable keys such as client GUID/resource ID, site code, MP/DP/SUP host, assignment/advertisement, CI/model, package/content/version, update/KB, task-sequence execution, BITS job, request/topic, and state message ID. | +| SccmTransaction | A time-normalized workflow instance with phases, participants, terminal state, supporting evidence, and explicitly missing expected evidence. | +| SccmFinding | A symptom, confirmed terminal failure, blocked/deferred state, likely contributor, or insufficient-evidence result. It includes phase, scope/role, severity, confidence, evidence references, correlation keys, remediation-safe next checks, and required next artifacts. | + +The raw parser must preserve the CCM context attribute in the SCCM evidence +model because SYSTEM versus user context can change the interpretation of an +application or task-sequence result. Exports redact that value by default. +SCCM signal extraction also preserves known and unknown HRESULT, Win32, +exit-code, return-code, hr=, status=, and [gle=] values. Highlighting a known +error code is useful UI metadata; it is not a sufficient diagnostic model. + +Correlation is deterministic first: stable identifiers and explicit +request/response relationships take precedence. Time proximity alone can +produce only low-confidence linkage. A single error line is a symptom unless a +terminal outcome or corroborating chain proves the affected phase and cause. + +### Evidence coverage and intake + +The later collectors must model source coverage before analyzers run: + +- Defaults are candidates, not universal truths. The source contract preserves + configured path provenance for clients, site servers, management points, + distribution points, and WSUS/SUP hosts. +- It collects a bounded, deterministic workload-priority set, then selected + incident bundles, current files, .lo_ files, and timestamped or numbered + rotations. The manifest records each expected source as captured, absent, + access-denied, capped, skipped, or unsupported. +- The current embedded profile stages only CCMSetup logs and a CCM registry + export. The SCCM intake issue must split that misleading entry into separate + CCMSetup and client-operational roots, add the true %SystemRoot%\CCM\Logs + source, and preserve rotations. +- Client intake includes deployment-output/CCMCache evidence and the + phase-dependent Task Sequence log locations. Server intake treats site, + management-point, distribution-point, and SUP paths as individually + discoverable role sources. +- Optional status-message, site-database, registry, IIS, Windows Update, + CBS/DISM, and deployment-output exports are first-class supplemental + artifacts. They never become hidden local-machine requirements of the pure + crate. + +### Client diagnostic streams + +Client analysis follows the actual deployment path, rather than asking users +to guess a log name: + +1. Setup and health: client installation, upgrade/repair, service lifecycle, + client evaluation, and reboot state. +2. Identity, assignment, and location: registration, certificate or Entra + authentication, site assignment, boundary/location resolution, and MP/SUP + selection. +3. Policy: request, download, persistence, scheduling, evaluation, and state + reporting. +4. Content and applications: intent/requirements, DP selection, BITS/cache + transfer, enforcement, detection, and final state message. +5. Software updates: scan/source location, compliance evaluation, download, + maintenance-window enforcement, install, reboot, and reporting. +6. Task sequence: WinPE through post-client log relocation, exact step, + content, command, and reboot outcomes. +7. Inventory, compliance, metering, co-management, scripts, notification, and + Software Center: each remains a distinct state-machine contract rather than + an “everything else” parser. + +Each stream owns its representative log bundle and a sanitized multifile +corpus. For example, application diagnosis correlates intent, discovery, +content, enforcement, post-install detection, and state message rather than +declaring an AppEnforce error the root cause. + +### Server diagnostic streams + +Server analysis is role-first because the site server, management point, +distribution point, and SUP often live on different hosts: + +1. Site core and status system: SMS Executive/site component health, hierarchy + changes, inbox processing, component monitoring, status/state-message + processing, and imported status-system exports. +2. Management point: client registration, authentication, location, policy, + relay/status, and client-notification request/response paths. +3. Distribution point and content distribution: distribution jobs, package + transfer, DP content-library/provider state, pull DP activity, and the + existing IIS parser as supplemental HTTP evidence. +4. Software update point: WSUS/SUP install and health, synchronization, + metadata/content processing, and the client-to-SUP location chain. +5. Hierarchy, replication, provider, and Admin Service: intersite send/receive + and replication flow, SMS Provider/Admin Service activity, and optional + database evidence whose provenance is explicit. +6. Later role tracks: OSD/PXE, notification, CMG/service connection, + reporting, and certificate enrollment. These remain planned leaves until + their input contracts and fixtures exist. + +### Cross-side diagnostic rule + +The correlation layer can connect a client transaction to MP, site, DP, or SUP +evidence only through stable keys and compatible role topology. It answers +questions such as “the client selected no usable DP” or “the DP content job +failed before the client attempted transfer,” with both sides cited. + +It must not turn ordinary latency, an unrelated server error, missing +collection, or same-minute events into causal proof. When evidence is +incomplete, the output explicitly names the next smallest artifact bundle to +collect. + +### Version, framing, and time controls + +Correlation and signal extraction operate only after the raw parser has +reassembled a logical record. A physical-line split, a rotation boundary, or +unmatched tail text must never cause a partial record to become a key-bearing +event. + +SCCM provenance retains the reported ConfigMgr version when the artifact +exposes it, the original local timestamp/display, and the parsed offset. The +existing raw CCM timestamp is normalized to UTC for ordering; SCCM analysis +must preserve the local form for evidence display and mark an unknown or +invalid offset as unresolved rather than inventing cross-host ordering. + +Correlation-key extractors are versioned heuristics, not protocol guarantees. +Each rule declares the source/version family it was validated against. A rule +that cannot safely extract a stable key produces an evidence/coverage gap or +low-confidence candidate, never a silent guessed match. When a source family +shows release-specific wording, stable promotion requires fixtures from at +least two observed versions. + +The first cross-side release is incremental: policy-to-MP and +content-to-DP pairs can ship as soon as both sides have validated contracts. +The broader correlation issue expands that graph; it does not block all +client/server value until every SCCM workflow exists. + +## Skeleton PR scope + +The skeleton PR will: + +1. Add this architecture document and crate-level docs. +2. Add the canonical family-module structure using re-exports or minimal + forwarding modules only. +3. Preserve all current behavior and public paths through deprecated façades. +4. Add compile-time/API tests for each new canonical current path. +5. Link the tracking issue and all concrete parser issues. + +It will not implement any new format parser or SCCM semantic analyzer. Each +new parser or analyzer belongs in a separate PR that closes its own issue. + +## Tracker issue policy + +Create one issue per concrete input contract, not per empty namespace or +facade. An issue must identify the actual source, sample corpus, detection +signature, output model, malformed-input behavior, and platform boundary. + +### SCCM diagnostic program + +The SCCM work is one parent epic with workflow-oriented child issues. A child +issue owns a source bundle, classifier/normalizer rules, correlation keys, +terminal states, findings, coverage gaps, sanitized multifile fixtures, and +acceptance assertions. It is deliberately not one issue per raw .log file, +because most Windows client and many server logs reuse the CCM grammar. + +The dependency gates are deliberate: shared contracts complete first; client +and server intake then proceed in parallel; each domain workflow depends on +its own intake foundation; and cross-side correlation first delivers the +validated policy-to-MP and content-to-DP pairs before expanding. + +Open these SCCM issues in this dependency order: + +The live checklist is [issue #317](https://github.com/adamgell/cmtraceopen/issues/317). +It links the shared contract [#318](https://github.com/adamgell/cmtraceopen/issues/318), +client intake [#319](https://github.com/adamgell/cmtraceopen/issues/319), +server intake [#335](https://github.com/adamgell/cmtraceopen/issues/335), and +the remaining workflow issues [#320](https://github.com/adamgell/cmtraceopen/issues/320) +through [#334](https://github.com/adamgell/cmtraceopen/issues/334). + +1. **Epic: SCCM end-to-end diagnostics for future Client and Server + workspaces.** Defines the product boundary and owns the child-issue + checklist; the two workspaces consume the results later and do not + duplicate diagnostic rules. +2. **Shared SCCM diagnostic contracts and source catalog.** Implement artifact + provenance/coverage, normalized evidence, privacy treatment for execution + context, typed known-and-unknown signal extraction, stable identifiers, + transactions, timeline, and finding confidence. +3. **SCCM Client intake, collection contracts, and corpus foundation.** Split + CCMSetup from client operational logs, capture deterministic priority + bundles and rotations, and add sanitized multifile fixtures plus + native-Windows validation. This issue must report partial/missing coverage + rather than silently omitting it. +4. **SCCM Server role-aware intake, collection contracts, and corpus + foundation.** Model role-specific site, MP, DP, SUP, provider, and hierarchy + candidates with configured-path provenance, deterministic role bundles, and + explicit source coverage. +5. **SCCM Client setup, health, identity, assignment, and location + diagnostics.** Cover client install/repair/service evaluation, client + identity and authentication, site assignment, boundary/location, and + management-point selection. +6. **SCCM Client policy diagnostics.** Cover policy request, transfer, + persistence, scheduling, evaluation, and state/status reporting. +7. **SCCM Client application, package, and content diagnostics.** Cover + intent/requirements/dependencies, source/DP selection, BITS/cache transfer, + enforcement, detection, and state reporting in a single deployment + transaction. +8. **SCCM Client software-update diagnostics.** Cover SUP location, + scan/evaluation, update content, maintenance windows, install/reboot, and + reporting, with explicit CBS/DISM/Windows Update supplemental evidence. +9. **SCCM Client task-sequence diagnostics.** Cover phase-aware SMSTS/TS + locations from WinPE through post-client operation, with step, content, + command, and reboot terminal states. +10. **SCCM Client inventory, compliance, and metering diagnostics.** Cover + provider collection, evaluation/remediation, report generation, and + state-message delivery without conflating them with deployment semantics. +11. **SCCM Client co-management, scripts, notification, and Software Center + diagnostics.** Distinguish workload hand-off, execution, user-facing + notification, and policy/reporting outcomes. +12. **SCCM Server site-core and status-system diagnostics.** Cover site + component health, inboxes, component monitoring, status/state-message + processing, and optional exported status evidence. +13. **SCCM Server management-point diagnostics.** Cover client registration, + authentication, location, policy, relay/status, and notification + transactions; correlate with client requests only where stable keys match. +14. **SCCM Server distribution-point and content-distribution diagnostics.** + Cover distribution jobs, package transfer, content-library/provider state, + pull-DP behavior, and supplemental IIS evidence. +15. **SCCM Server software-update-point diagnostics.** Cover SUP/WSUS + install/health, synchronization, metadata/content processing, and the + client-to-SUP location chain. +16. **SCCM Server hierarchy and replication diagnostics.** Cover intersite + transport/replication, sender/receiver state, and explicit optional + database evidence. +17. **SCCM Server SMS Provider and Admin Service diagnostics.** Cover console, + provider, and Admin Service paths without treating those artifacts as + site-core or client-deployment evidence. +18. **SCCM Client-to-Server correlation and causal findings.** Build the + deterministic client-to-MP/site/DP/SUP graph, require corroboration before + a root-cause conclusion, and return the next minimal requested artifact + when confidence is insufficient. +19. **SCCM advanced server role contracts.** Establish independently + testable OSD/PXE, notification, CMG/service connection, reporting, and + certificate-enrollment subtracks. It may create separate implementation + issues only after each role's actual source bundle is verified. + +The later SCCM Client workspace and SCCM Server workspace are explicitly +downstream consumers of this program. Their future UI issues can begin once +shared contracts and one client/server workflow provide stable fixture-backed +outputs. + +Other initial tracker candidates: + +1. Intune Windows Win32 app-install evidence parser. +2. Intune Windows Microsoft Store app evidence parser. +3. Intune Windows platform-script evidence parser. +4. Intune Windows remediation evidence parser. +5. Intune macOS app-management package and shell-script evidence parser. +6. Intune Windows Autopilot evidence parser. +7. Intune Windows configuration evidence parser. +8. Intune Windows compliance evidence parser. +9. Intune Windows Update for Business evidence parser. +10. Company Portal for Windows parser. +11. Company Portal for macOS parser. +12. Company Portal for Android imported-diagnostics parser. +13. Company Portal for iOS/iPadOS imported-diagnostics parser. + +Existing parsers do not receive duplicates: MSI and PSADT are already tracked +by issue #23, and historic CCM/parser-location issues remain separate from +this semantic-analysis program. The current CCM, IME, ESP, CMTLOG, Patch My +PC, Windows, DNS, DHCP, IIS, Registry, and Secure Boot paths are migration +work in the skeleton PR rather than new-format work. + +## Verification + +The skeleton PR must pass: + +```text +cargo test -p cmtraceopen-parser +cargo clippy -p cmtraceopen-parser --all-targets -- -D warnings +cargo fmt --check --all +git diff --check +``` + +For every follow-up parser issue, acceptance requires a realistic fixture, +positive detection test, negative/malformed fixture, encoding coverage where +applicable, output-contract assertions, and native Windows validation when the +source uses Windows-only APIs. + +For every SCCM analyzer issue, acceptance additionally requires a multifile +transaction fixture with source-coverage assertions, deterministic ordering, +one completed workflow, one terminal or blocked workflow, one contradictory or +incomplete-evidence case, stable-correlation tests, and an assertion that the +finding cites its exact supporting entries. A high-confidence diagnosis must +not pass from a single severity/error-string match. + +Key and signal extraction tests operate on logical records, not physical lines. +Cross-side tests normalize timestamps to UTC while retaining original local +evidence. A rule that encounters an unvalidated version or unresolved time +offset must lower confidence or request evidence rather than manufacture a +causal ordering. + +## Non-goals + +- No new parser implementation is included in the skeleton PR. +- No module claims support merely because its namespace exists. +- No native filesystem/event-log dependency is added to the default pure crate. +- No existing parser behavior is renamed or removed without a compatibility + window and a semver-major release plan. diff --git a/library.md b/library.md new file mode 100644 index 000000000..db5ce0c9c --- /dev/null +++ b/library.md @@ -0,0 +1,7 @@ +# CMTrace Open — Workspace Library + +- IF implementing or reviewing SCCM issue #321 client policy production analysis → read [[docs/superpowers/plans/2026-08-04-sccm-321-policy-production.md]] +- IF implementing or reviewing SCCM issue #333 client/server production correlation → read [[docs/superpowers/plans/2026-08-04-sccm-333-production-correlation.md]] +- IF reviewing SCCM issue #333 executable correlation fixture oracles → read [[crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md]] +- IF integrating SCCM Epic #317 with current main for PR #490 → read [[docs/superpowers/plans/2026-08-04-sccm-317-main-integration.md]] +- IF repairing PR #490 native SCCM discovery/capture or workspace product path → read [[docs/superpowers/plans/2026-08-04-sccm-native-product-path-rework.md]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2e1c1f3e3..c52bb2488 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ tauri-build = { version = "2", features = [] } [features] default = ["full"] -full = ["collector", "deployment", "dsregcmd", "esp-diagnostics", "event-log", "intune-diagnostics", "macos-diag", "secureboot", "sysmon"] +full = ["collector", "deployment", "dsregcmd", "esp-diagnostics", "event-log", "intune-diagnostics", "macos-diag", "sccm-diagnostics", "secureboot", "sysmon"] sysmon = ["dep:evtx"] event-log = ["dep:evtx"] collector = [] @@ -33,6 +33,7 @@ esp-diagnostics = ["intune-diagnostics", "dep:cab", "dep:tempfile", "dep:zip"] intune-diagnostics = ["dep:evtx", "dep:quick-xml"] macos-diag = ["dep:plist"] secureboot = ["dep:tempfile"] +sccm-diagnostics = [] [dependencies] cmtraceopen-parser = { path = "../crates/cmtraceopen-parser", version = "0.1" } @@ -88,6 +89,7 @@ windows = { version = "=0.62.2", features = [ "Win32_Security_Cryptography", "Win32_Security_Authorization", "Win32_System_EventLog", + "Win32_System_IO", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Com", @@ -104,6 +106,8 @@ windows = { version = "=0.62.2", features = [ "Foundation", "Foundation_Collections", "Win32_System_WinRT", + "Wdk_Foundation", + "Wdk_Storage_FileSystem", ] } windows-future = "=0.3.2" # ureq 3.3 uses Cargo's 2024 edition and requires Rust 1.85, which the old @@ -127,6 +131,18 @@ required-features = ["esp-diagnostics"] name = "sysmon_parser" required-features = ["sysmon"] +[[test]] +name = "sccm_client_manifest" +required-features = ["sccm-diagnostics"] + +[[test]] +name = "sccm_client_discovery" +required-features = ["sccm-diagnostics"] + +[[test]] +name = "sccm_native_collection" +required-features = ["sccm-diagnostics"] + [[bench]] name = "intune_pipeline" harness = false diff --git a/src-tauri/src/commands/app_config.rs b/src-tauri/src/commands/app_config.rs index 70e3e57b7..ee9e5214e 100644 --- a/src-tauri/src/commands/app_config.rs +++ b/src-tauri/src/commands/app_config.rs @@ -94,6 +94,10 @@ pub fn get_available_workspaces() -> Vec<&'static str> { workspaces.push("secureboot"); } + if cfg!(feature = "sccm-diagnostics") { + workspaces.push("sccm"); + } + workspaces.push("timeline"); workspaces.push("dns-dhcp"); @@ -128,4 +132,12 @@ mod tests { cfg!(feature = "esp-diagnostics") ); } + + #[test] + fn sccm_workspace_availability_matches_the_build_feature() { + assert_eq!( + get_available_workspaces().contains(&"sccm"), + cfg!(feature = "sccm-diagnostics") + ); + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a5ae3350b..0c69b0d47 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -33,6 +33,8 @@ pub mod parsing; pub mod recent_entries; pub mod registry_ops; pub mod reveal; +#[cfg(feature = "sccm-diagnostics")] +pub mod sccm; #[cfg(feature = "secureboot")] pub mod secureboot; #[cfg(feature = "sysmon")] diff --git a/src-tauri/src/commands/recent_entries.rs b/src-tauri/src/commands/recent_entries.rs index eb4e578e2..1fe1dd628 100644 --- a/src-tauri/src/commands/recent_entries.rs +++ b/src-tauri/src/commands/recent_entries.rs @@ -532,10 +532,7 @@ mod tests { use std::thread; let dir = tempdir().expect("tempdir"); - let state = Arc::new(RecentEntriesState::load( - dir.path().to_path_buf(), - &["log"], - )); + let state = Arc::new(RecentEntriesState::load(dir.path().to_path_buf(), &["log"])); let threads: Vec<_> = (0..8) .map(|index| { diff --git a/src-tauri/src/commands/sccm.rs b/src-tauri/src/commands/sccm.rs new file mode 100644 index 000000000..b950fc59e --- /dev/null +++ b/src-tauri/src/commands/sccm.rs @@ -0,0 +1,176 @@ +use std::fs; +use std::path::Path; + +use tauri::Manager; + +use crate::error::AppError; +use crate::sccm::collector::{ + capture_discovered_environment, discover_capture_environment, discover_environment_with, + NativeDiscoveryProvider, SccmCaptureResult, SccmCollectorError, SccmDiscoveryProvider, + SccmEnvironmentDiscovery, +}; + +fn collector_error(error: SccmCollectorError) -> AppError { + AppError::Analysis(error.code().to_owned()) +} + +pub(crate) fn discover_with_provider( + provider: &dyn SccmDiscoveryProvider, +) -> Result { + discover_environment_with(provider) + .map_err(|_| AppError::Analysis("discoveryFailed".to_owned())) +} + +pub(crate) fn capture_with_provider( + provider: &dyn SccmDiscoveryProvider, + cache_root: &Path, +) -> Result { + let environment = discover_capture_environment(provider).map_err(collector_error)?; + let collection_root = cache_root.join("sccm"); + fs::create_dir_all(&collection_root) + .map_err(|_| collector_error(SccmCollectorError::DestinationUnavailable))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&collection_root, fs::Permissions::from_mode(0o700)) + .map_err(|_| collector_error(SccmCollectorError::DestinationUnavailable))?; + } + let bundle_root = collection_root.join(uuid::Uuid::new_v4().to_string()); + capture_discovered_environment(environment, &bundle_root).map_err(collector_error) +} + +#[tauri::command] +pub fn discover_sccm_environment() -> Result { + discover_with_provider(&NativeDiscoveryProvider) +} + +#[tauri::command] +pub fn capture_sccm_diagnostics(app: tauri::AppHandle) -> Result { + let cache_root = app + .path() + .app_cache_dir() + .map_err(|_| collector_error(SccmCollectorError::DestinationUnavailable))?; + capture_with_provider(&NativeDiscoveryProvider, &cache_root) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sccm::collector::{ + PrivateSccmEnvironment, SccmCaptureRoot, SccmDetectedRole, SccmDiscoveryBasis, + SccmDiscoveryFailure, + }; + use crate::sccm::SccmRole; + + struct FakeProvider { + environment: PrivateSccmEnvironment, + } + + impl SccmDiscoveryProvider for FakeProvider { + fn discover(&self) -> Result { + Ok(self.environment.clone()) + } + } + + #[test] + fn discovery_does_not_write_to_the_working_directory() { + let working = tempfile::tempdir().expect("temporary working directory"); + let provider = FakeProvider { + environment: PrivateSccmEnvironment { + supported: true, + roles: vec![SccmDetectedRole { + role: SccmRole::Client, + basis: SccmDiscoveryBasis::Registry, + }], + ..PrivateSccmEnvironment::default() + }, + }; + let before = fs::read_dir(working.path()).unwrap().count(); + let discovery = discover_with_provider(&provider).expect("discovery"); + assert!(discovery.supported); + assert_eq!(fs::read_dir(working.path()).unwrap().count(), before); + } + + #[test] + fn capture_chooses_a_uuid_bundle_below_the_cache_root() { + let cache = tempfile::tempdir().expect("temporary cache"); + let logs = tempfile::tempdir().expect("temporary logs"); + fs::write(logs.path().join("PolicyAgent.log"), b"policy").unwrap(); + let provider = FakeProvider { + environment: PrivateSccmEnvironment { + supported: true, + roles: vec![SccmDetectedRole { + role: SccmRole::Client, + basis: SccmDiscoveryBasis::Registry, + }], + roots: vec![SccmCaptureRoot { + role: SccmRole::Client, + path: logs.path().to_owned(), + }], + ..PrivateSccmEnvironment::default() + }, + }; + + let result = capture_with_provider(&provider, cache.path()).expect("capture"); + let bundle = Path::new(&result.bundle_root); + assert_eq!(bundle.parent(), Some(cache.path().join("sccm").as_path())); + assert!(uuid::Uuid::parse_str(bundle.file_name().unwrap().to_str().unwrap()).is_ok()); + assert!(bundle.join("sccm-manifest.json").is_file()); + } + + #[test] + fn command_errors_expose_only_generic_codes() { + let cache = tempfile::tempdir().expect("temporary cache"); + let provider = FakeProvider { + environment: PrivateSccmEnvironment { + supported: true, + roles: vec![SccmDetectedRole { + role: SccmRole::Client, + basis: SccmDiscoveryBasis::Registry, + }], + roots: vec![SccmCaptureRoot { + role: SccmRole::Client, + path: cache.path().join("private-sentinel"), + }], + ..PrivateSccmEnvironment::default() + }, + }; + let file_cache = cache.path().join("not-a-directory"); + fs::write(&file_cache, b"occupied").unwrap(); + let error = capture_with_provider(&provider, &file_cache).unwrap_err(); + assert_eq!(error.to_string(), "Analysis failed: destinationUnavailable"); + assert!(!error.to_string().contains("private-sentinel")); + } + + #[test] + fn capture_rejects_supported_environment_without_roles_before_writing() { + let cache = tempfile::tempdir().expect("temporary cache"); + let provider = FakeProvider { + environment: PrivateSccmEnvironment { + supported: true, + ..PrivateSccmEnvironment::default() + }, + }; + + let error = capture_with_provider(&provider, cache.path()).unwrap_err(); + assert_eq!(error.to_string(), "Analysis failed: noRolesDetected"); + assert!(!cache.path().join("sccm").exists()); + assert_eq!(fs::read_dir(cache.path()).unwrap().count(), 0); + } + + #[test] + fn capture_rejects_unsupported_environment_before_writing() { + let cache = tempfile::tempdir().expect("temporary cache"); + let provider = FakeProvider { + environment: PrivateSccmEnvironment { + supported: false, + ..PrivateSccmEnvironment::default() + }, + }; + + let error = capture_with_provider(&provider, cache.path()).unwrap_err(); + assert_eq!(error.to_string(), "Analysis failed: noRolesDetected"); + assert!(!cache.path().join("sccm").exists()); + assert_eq!(fs::read_dir(cache.path()).unwrap().count(), 0); + } +} diff --git a/src-tauri/src/commands/system_preferences.rs b/src-tauri/src/commands/system_preferences.rs index ad94742e2..fd429b2bd 100644 --- a/src-tauri/src/commands/system_preferences.rs +++ b/src-tauri/src/commands/system_preferences.rs @@ -116,8 +116,7 @@ pub fn set_always_on_top( } if let Some(menu) = app.menu() { - if let Some(MenuItemKind::Check(item)) = - menu.get(crate::menu::MENU_ID_WINDOW_ALWAYS_ON_TOP) + if let Some(MenuItemKind::Check(item)) = menu.get(crate::menu::MENU_ID_WINDOW_ALWAYS_ON_TOP) { let _ = item.set_checked(enabled); } diff --git a/src-tauri/src/esp/process.rs b/src-tauri/src/esp/process.rs index 51fd3ec5b..7a2378470 100644 --- a/src-tauri/src/esp/process.rs +++ b/src-tauri/src/esp/process.rs @@ -442,8 +442,7 @@ fn sanitize_json_command_value(value: &mut serde_json::Value) -> bool { fn redact_cross_element_string_secrets(values: &mut [serde_json::Value]) -> bool { let mut changed = false; for index in 0..values.len().saturating_sub(1) { - let (Some(prefix), Some(candidate)) = - (values[index].as_str(), values[index + 1].as_str()) + let (Some(prefix), Some(candidate)) = (values[index].as_str(), values[index + 1].as_str()) else { continue; }; diff --git a/src-tauri/src/esp/registry.rs b/src-tauri/src/esp/registry.rs index 7859682df..8a227bc87 100644 --- a/src-tauri/src/esp/registry.rs +++ b/src-tauri/src/esp/registry.rs @@ -743,7 +743,11 @@ fn node_cache_contains_hardware_identity(entry: &RegistrySnapshotKey) -> bool { }) } -fn registry_sensitivity(key: &str, value_name: &str, value: &EspObservationValue) -> EspSensitivity { +fn registry_sensitivity( + key: &str, + value_name: &str, + value: &EspObservationValue, +) -> EspSensitivity { let path_sensitivity = registry_path_sensitivity(key); if path_sensitivity != EspSensitivity::Public { return path_sensitivity; diff --git a/src-tauri/src/esp/system.rs b/src-tauri/src/esp/system.rs index 848edf50f..3589881af 100644 --- a/src-tauri/src/esp/system.rs +++ b/src-tauri/src/esp/system.rs @@ -1573,6 +1573,9 @@ mod windows_provider { use windows::core::{BSTR, HRESULT, PCWSTR}; use windows::Win32::Foundation::{CloseHandle, E_ACCESSDENIED, HANDLE, RPC_E_CHANGED_MODE}; + use windows::Win32::NetworkManagement::NetManagement::{ + NetFreeAadJoinInformation, NetGetAadJoinInformation, + }; use windows::Win32::Security::{ GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, }; @@ -1581,9 +1584,6 @@ mod windows_provider { CoSetProxyBlanket, CoUninitialize, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, EOAC_NONE, RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE, }; - use windows::Win32::NetworkManagement::NetManagement::{ - NetFreeAadJoinInformation, NetGetAadJoinInformation, - }; use windows::Win32::System::SystemInformation::GetSystemWindowsDirectoryW; use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use windows::Win32::System::Variant::{VariantClear, VariantToString, VARIANT}; diff --git a/src-tauri/src/graph_api/esp.rs b/src-tauri/src/graph_api/esp.rs index 068d05e63..9c547a346 100644 --- a/src-tauri/src/graph_api/esp.rs +++ b/src-tauri/src/graph_api/esp.rs @@ -2556,12 +2556,8 @@ mod overlay_tests { let mut request = base_request(); request.app_ids = vec![APP_GUID.to_string()]; - let overlay = fetch_esp_graph_overlay( - &provider, - &request, - &NeverCancelled, - "2026-01-01T00:00:00Z", - ); + let overlay = + fetch_esp_graph_overlay(&provider, &request, &NeverCancelled, "2026-01-01T00:00:00Z"); assert_eq!(overlay.device_match.status, GraphSectionStatus::Available); assert_eq!(overlay.apps.status, GraphSectionStatus::Available); @@ -2581,12 +2577,8 @@ mod overlay_tests { }; let request = base_request(); // app_ids and workload_ids left empty - let overlay = fetch_esp_graph_overlay( - &provider, - &request, - &NeverCancelled, - "2026-01-01T00:00:00Z", - ); + let overlay = + fetch_esp_graph_overlay(&provider, &request, &NeverCancelled, "2026-01-01T00:00:00Z"); assert_eq!(overlay.device_match.status, GraphSectionStatus::Available); assert_eq!(overlay.apps.status, GraphSectionStatus::Skipped); diff --git a/src-tauri/src/graph_api/models.rs b/src-tauri/src/graph_api/models.rs index 7df31aa33..0c3ea0bab 100644 --- a/src-tauri/src/graph_api/models.rs +++ b/src-tauri/src/graph_api/models.rs @@ -199,10 +199,7 @@ pub fn classify_graph_permission_candidate( // object id is absent or unverifiable on either side so a same-tenant, // different-account token can never replace the connected token — even when // the optional WAM `UserName` (UPN) is missing for federated/guest accounts. - let account_matches = match ( - current.object_id.as_deref(), - candidate.object_id.as_deref(), - ) { + let account_matches = match (current.object_id.as_deref(), candidate.object_id.as_deref()) { (Some(current_oid), Some(candidate_oid)) => current_oid.eq_ignore_ascii_case(candidate_oid), _ => false, }; diff --git a/src-tauri/src/intune/evtx_parser.rs b/src-tauri/src/intune/evtx_parser.rs index 38cc55887..89fe6c507 100644 --- a/src-tauri/src/intune/evtx_parser.rs +++ b/src-tauri/src/intune/evtx_parser.rs @@ -1747,8 +1747,7 @@ mod tests { // More than the per-element cap is rejected before quick-xml's O(n^2) // duplicate-attribute check (RUSTSEC-2026-0194) can blow up. - let oversized = - esp_record_xml_with_root_attributes(MAX_ESP_XML_ATTRIBUTES_PER_ELEMENT + 1); + let oversized = esp_record_xml_with_root_attributes(MAX_ESP_XML_ATTRIBUTES_PER_ELEMENT + 1); let started = std::time::Instant::now(); assert!( parse_esp_event_xml(&oversized, "attr-cap.evtx", Some(1), None, "Unknown").is_none(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ba504a22..1f3d246be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,8 @@ mod menu; pub use cmtraceopen_parser::models; pub mod parser; pub mod process_util; +#[cfg(feature = "sccm-diagnostics")] +pub mod sccm; #[cfg(feature = "secureboot")] pub mod secureboot; mod state; @@ -239,6 +241,10 @@ pub fn run() { commands::file_association::set_file_association_prompt_suppressed, commands::app_config::get_available_workspaces, commands::app_config::get_update_policy, + #[cfg(feature = "sccm-diagnostics")] + commands::sccm::discover_sccm_environment, + #[cfg(feature = "sccm-diagnostics")] + commands::sccm::capture_sccm_diagnostics, commands::recent_entries::push_recent_entry, commands::recent_entries::clear_recent_entries, menu::sync_app_menu_state, diff --git a/src-tauri/src/macos_diag/unified_log.rs b/src-tauri/src/macos_diag/unified_log.rs index 5bb678d26..5b672b3f5 100644 --- a/src-tauri/src/macos_diag/unified_log.rs +++ b/src-tauri/src/macos_diag/unified_log.rs @@ -266,9 +266,7 @@ mod tests { fn test_parse_ndjson_log_entries_capped() { let line = r#"{"timestamp":"2024-01-01 00:00:00.000000-0000","processImagePath":"/usr/bin/test","messageType":"Info","eventMessage":"msg","processID":1}"#; // Create 10 lines - let input = std::iter::repeat_n(line, 10) - .collect::>() - .join("\n"); + let input = std::iter::repeat_n(line, 10).collect::>().join("\n"); let (entries, total, capped) = parse_ndjson_log_entries(&input, 3); assert_eq!(entries.len(), 3); diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index f2594205f..4e3767737 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -302,6 +302,12 @@ const WORKSPACE_DESCRIPTORS: &[WorkspaceDescriptor] = &[ group: WorkspaceGroup::EndpointManagement, platform: WorkspacePlatform::All, }, + WorkspaceDescriptor { + id: "sccm", + label: "SCCM Diagnostics", + group: WorkspaceGroup::EndpointManagement, + platform: WorkspacePlatform::Windows, + }, WorkspaceDescriptor { id: "macos-jamf", label: "macOS JAMF", @@ -1338,7 +1344,10 @@ fn recent_entry_hash(entry: &RecentEntry) -> String { /// (concurrent pushes, or a prune dropping an earlier row) — so the hash lets /// `enrich_recent_payload` detect a stale index before acting on it. fn recent_menu_id(index: usize, entry: &RecentEntry) -> String { - format!("{RECENT_MENU_ID_PREFIX}{index}.{}", recent_entry_hash(entry)) + format!( + "{RECENT_MENU_ID_PREFIX}{index}.{}", + recent_entry_hash(entry) + ) } /// Inverse of `recent_menu_id`: splits `recent.{index}.{hash}` into its parts. @@ -1957,6 +1966,7 @@ mod tests { "intune", "new-intune", "esp-diagnostics", + "sccm", "dsregcmd", "deployment", ], @@ -2345,10 +2355,7 @@ mod tests { opened_at_unix_ms: 0, }; - assert_eq!( - recent_entry_label(&entry), - "IME — bundle-01 (Log Explorer)" - ); + assert_eq!(recent_entry_label(&entry), "IME — bundle-01 (Log Explorer)"); } #[test] @@ -2390,7 +2397,8 @@ mod tests { assert_eq!(hash.len(), 16, "expected a full 64-bit digest"); assert!( - hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + hash.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), "expected lowercase hex, got {hash}" ); } diff --git a/src-tauri/src/sccm/collector/client_manifest.rs b/src-tauri/src/sccm/collector/client_manifest.rs new file mode 100644 index 000000000..c27ec0e13 --- /dev/null +++ b/src-tauri/src/sccm/collector/client_manifest.rs @@ -0,0 +1,222 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +use cmtraceopen_parser::sccm::SCCM_DIAGNOSTICS_SCHEMA_VERSION; + +use crate::sccm::contract::{ + catalog_entry_id, compare_manifest_artifacts, compare_manifest_capture_gaps, + expected_bundle_group, expected_marker_artifact_id, expected_physical_artifact_id, + logical_artifact_ids_for_basename, root_handle_digest, rotation_segment, sha256_bytes, + source_identity_digest, SccmBundleManifestV1, SccmCaptureLimitKind, SccmManifestArtifact, + SccmManifestCaptureGap, SccmManifestCoverageScope, SccmManifestProvenance, + SccmManifestProvenanceProfile, SccmManifestSourceState, SCCM_CLIENT_SOURCE_CATALOG_VERSION, + SCCM_MANIFEST_FILE_NAME, SCCM_MANIFEST_VERSION, +}; +use crate::sccm::{read_sccm_client_intake_bundle, SccmRole, SccmRotation}; + +use super::{SccmCollectorError, MAX_BYTES_PER_SOURCE, MAX_FRAGMENTS_PER_SOURCE}; + +#[derive(Debug, Clone)] +pub(super) struct CapturedClientArtifact { + pub root_handle: String, + pub basename: String, + pub rotation: SccmRotation, + pub state: SccmManifestSourceState, + pub relative_path: String, + pub retained_bytes: u64, + pub content_sha256: String, + pub capped: bool, +} + +#[derive(Debug, Clone)] +pub(super) struct ClientCoverageRecord { + pub root_handle: String, + pub basename: String, + pub rotation: SccmRotation, + pub state: SccmManifestSourceState, + pub coverage_scope: SccmManifestCoverageScope, + pub gap_limit: Option, + pub source_bytes: u64, +} + +pub(super) fn client_relative_path( + root_handle: &str, + basename: &str, + rotation: &SccmRotation, +) -> String { + let memberships = logical_artifact_ids_for_basename(basename); + format!( + "evidence/sccm/client/{}/{}/{}/{}", + expected_bundle_group(&memberships), + root_handle, + rotation_segment(rotation), + physical_basename(basename, rotation) + ) +} + +fn physical_basename(basename: &str, rotation: &SccmRotation) -> String { + match rotation { + SccmRotation::Current => basename.to_owned(), + SccmRotation::LoUnderscore => { + format!("{}.lo_", basename.strip_suffix(".log").unwrap_or(basename)) + } + SccmRotation::Numbered(number) => format!("{basename}.{number}"), + SccmRotation::Timestamped(timestamp) => format!("{basename}.{timestamp}"), + SccmRotation::Unknown(_) => basename.to_owned(), + } +} + +pub(super) fn write_and_validate_client_manifest( + bundle_root: &Path, + collected_at_utc: &str, + configmgr_version: Option<&str>, + mut captured: Vec, + coverage: Vec, +) -> Result<(), SccmCollectorError> { + let mut artifacts = captured + .drain(..) + .map(|captured| { + let canonical_basename = captured.basename.clone(); + let source_digest = source_identity_digest(&captured.root_handle, &canonical_basename) + .ok_or(SccmCollectorError::ManifestValidationFailed)?; + let path_fingerprint = format!("sha256:{source_digest}"); + let lineage_digest = sha256_bytes(format!("lineage:v1:{source_digest}").as_bytes()); + let physical_name = physical_basename(&canonical_basename, &captured.rotation); + Ok(SccmManifestArtifact { + catalog_entry_id: catalog_entry_id(&canonical_basename), + logical_artifact_ids: logical_artifact_ids_for_basename(&canonical_basename), + artifact_id: expected_physical_artifact_id( + &path_fingerprint, + &captured.rotation, + &physical_name, + ), + role: SccmRole::Client, + source_handle: Some(format!("cmtraceopen.source.sha256.v1:{source_digest}")), + root_handle: Some(captured.root_handle), + path_fingerprint: Some(path_fingerprint), + rotation_lineage: Some(format!("cmtraceopen.lineage.sha256.v1:{lineage_digest}")), + relative_path: Some(captured.relative_path), + basename: physical_name, + rotation: captured.rotation, + state: captured.state, + coverage_scope: SccmManifestCoverageScope::Source, + capture_limit_kind: captured.capped.then_some(SccmCaptureLimitKind::Bytes), + bytes_copied: captured.retained_bytes, + limit_applied: captured.capped.then_some(captured.retained_bytes), + content_sha256: Some(captured.content_sha256), + fragment_complete: !captured.capped, + configmgr_version: configmgr_version.map(str::to_owned), + collected_at_utc: Some(collected_at_utc.to_owned()), + encoding: Some("utf-8".to_owned()), + }) + }) + .collect::, SccmCollectorError>>()?; + let mut capture_gaps = Vec::new(); + for record in coverage { + let canonical_basename = record.basename.clone(); + let physical_name = physical_basename(&canonical_basename, &record.rotation); + let source_digest = if record.coverage_scope == SccmManifestCoverageScope::RootEnumeration { + let root_digest = root_handle_digest(&record.root_handle) + .ok_or(SccmCollectorError::ManifestValidationFailed)?; + sha256_bytes( + format!( + "cmtraceopen.sccm.root-enumeration.v1\0{root_digest}\0{canonical_basename}" + ) + .as_bytes(), + ) + } else { + source_identity_digest(&record.root_handle, &canonical_basename) + .ok_or(SccmCollectorError::ManifestValidationFailed)? + }; + let source_handle = format!("cmtraceopen.source.sha256.v1:{source_digest}"); + let path_fingerprint = format!("sha256:{source_digest}"); + let lineage = format!( + "cmtraceopen.lineage.sha256.v1:{}", + sha256_bytes(format!("lineage:v1:{source_digest}").as_bytes()) + ); + let entry_id = catalog_entry_id(&canonical_basename); + let logical_ids = logical_artifact_ids_for_basename(&canonical_basename); + let artifact_id = expected_marker_artifact_id( + &entry_id, + record.state, + &record.rotation, + &physical_name, + Some(&path_fingerprint), + ); + if let Some(capture_limit_kind) = record.gap_limit { + capture_gaps.push(SccmManifestCaptureGap { + artifact_id, + catalog_entry_id: entry_id, + logical_artifact_ids: logical_ids, + source_handle, + root_handle: record.root_handle, + path_fingerprint, + rotation_lineage: lineage, + basename: physical_name, + rotation: record.rotation, + state: record.state, + capture_limit_kind, + source_bytes: record.source_bytes, + bytes_retained: 0, + }); + } else { + artifacts.push(SccmManifestArtifact { + catalog_entry_id: entry_id, + logical_artifact_ids: logical_ids, + artifact_id, + role: SccmRole::Client, + source_handle: Some(source_handle), + root_handle: Some(record.root_handle), + path_fingerprint: Some(path_fingerprint), + rotation_lineage: Some(lineage), + relative_path: None, + basename: physical_name, + rotation: record.rotation, + state: record.state, + coverage_scope: record.coverage_scope, + capture_limit_kind: None, + bytes_copied: 0, + limit_applied: None, + content_sha256: None, + fragment_complete: false, + configmgr_version: configmgr_version.map(str::to_owned), + collected_at_utc: Some(collected_at_utc.to_owned()), + encoding: None, + }); + } + } + artifacts.sort_by(compare_manifest_artifacts); + capture_gaps.sort_by(compare_manifest_capture_gaps); + + let manifest = SccmBundleManifestV1 { + sccm_manifest_version: SCCM_MANIFEST_VERSION, + diagnostics_schema_version: SCCM_DIAGNOSTICS_SCHEMA_VERSION, + source_catalog_version: SCCM_CLIENT_SOURCE_CATALOG_VERSION, + provenance: SccmManifestProvenance::NativeClientCapture, + provenance_profile: SccmManifestProvenanceProfile::HmacSha256V1, + host_handle: None, + collected_at_utc: Some(collected_at_utc.to_owned()), + max_files_per_source: MAX_FRAGMENTS_PER_SOURCE, + max_bytes_per_source: MAX_BYTES_PER_SOURCE, + artifacts, + capture_gaps, + }; + let bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|_| SccmCollectorError::ManifestValidationFailed)?; + let path = bundle_root.join(SCCM_MANIFEST_FILE_NAME); + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|_| SccmCollectorError::DestinationUnavailable)?; + output + .write_all(&bytes) + .map_err(|_| SccmCollectorError::CaptureFailed)?; + output + .sync_all() + .map_err(|_| SccmCollectorError::CaptureFailed)?; + read_sccm_client_intake_bundle(bundle_root) + .map_err(|_| SccmCollectorError::ManifestValidationFailed)?; + Ok(()) +} diff --git a/src-tauri/src/sccm/collector/discovery.rs b/src-tauri/src/sccm/collector/discovery.rs new file mode 100644 index 000000000..4f18d78d1 --- /dev/null +++ b/src-tauri/src/sccm/collector/discovery.rs @@ -0,0 +1,380 @@ +use std::collections::BTreeSet; + +use super::{ + normalize_public_discovery, role_key, PrivateSccmEnvironment, SccmCaptureRoot, + SccmDiscoveryFailure, SccmDiscoveryProvider, SccmEnvironmentDiscovery, +}; + +#[cfg(any(test, target_os = "windows"))] +use super::{SccmDetectedRole, SccmDiscoveryBasis}; +use super::{SccmDiscoveryIssue, SccmDiscoveryIssueCode}; +#[cfg(any(test, target_os = "windows"))] +use crate::sccm::SccmRole; + +#[cfg(any(test, target_os = "windows"))] +const FIXED_ROLE_QUERY: &str = "$ErrorActionPreference='Stop'; Get-CimInstance -Namespace 'root/cimv2' -ClassName 'Win32_Service' -Filter \"Name='CcmExec' OR Name='SMS_EXECUTIVE' OR Name='SMS_ADMIN_SERVICE' OR Name='WSUSService'\" | Select-Object -ExpandProperty Name | ConvertTo-Json -Compress"; + +#[derive(Debug, Default)] +pub struct NativeDiscoveryProvider; + +impl SccmDiscoveryProvider for NativeDiscoveryProvider { + fn discover(&self) -> Result { + discover_native() + } +} + +pub fn discover_environment() -> Result { + discover_environment_with(&NativeDiscoveryProvider) +} + +pub fn discover_environment_with( + provider: &dyn SccmDiscoveryProvider, +) -> Result { + let mut private = provider.discover()?; + normalize_private_roots(&mut private.roots); + Ok(normalize_public_discovery(SccmEnvironmentDiscovery { + supported: private.supported, + configmgr_version: private.configmgr_version, + roles: private.roles, + sources: Vec::new(), + issues: private.issues, + })) +} + +pub(crate) fn normalized_private_environment( + provider: &dyn SccmDiscoveryProvider, +) -> Result { + let mut environment = provider.discover()?; + normalize_private_roots(&mut environment.roots); + environment + .roles + .sort_by_key(|role| (role_key(&role.role), role.basis)); + environment.roles.dedup(); + environment.issues.sort_by_key(|issue| { + ( + issue.code, + issue.role.as_ref().map(role_key).unwrap_or_default(), + ) + }); + environment.issues.dedup(); + Ok(environment) +} + +fn normalize_private_roots(roots: &mut Vec) { + roots.sort_by_key(|root| { + ( + role_key(&root.role), + root.path + .to_string_lossy() + .replace('\\', "/") + .to_lowercase(), + ) + }); + let mut seen = BTreeSet::new(); + roots.retain(|root| { + seen.insert(( + role_key(&root.role), + root.path + .to_string_lossy() + .replace('\\', "/") + .to_lowercase(), + )) + }); +} + +#[cfg(not(target_os = "windows"))] +fn discover_native() -> Result { + Ok(PrivateSccmEnvironment { + supported: false, + issues: vec![SccmDiscoveryIssue { + code: SccmDiscoveryIssueCode::UnsupportedPlatform, + role: None, + }], + ..PrivateSccmEnvironment::default() + }) +} + +#[cfg(target_os = "windows")] +fn discover_native() -> Result { + use std::path::PathBuf; + use std::process::Command; + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + + const CLIENT_SETUP_KEY: &str = r"SOFTWARE\Microsoft\CCM\Setup"; + const SITE_SERVER_KEY: &str = r"SOFTWARE\Microsoft\SMS\Setup"; + + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let mut environment = PrivateSccmEnvironment { + supported: true, + private_host: std::env::var("COMPUTERNAME").ok(), + ..PrivateSccmEnvironment::default() + }; + + match hklm.open_subkey(CLIENT_SETUP_KEY) { + Ok(key) => { + environment.roles.push(SccmDetectedRole { + role: SccmRole::Client, + basis: SccmDiscoveryBasis::Registry, + }); + environment.configmgr_version = key.get_value::("ProductVersion").ok(); + if let Some(path) = client_log_root() { + environment.roots.push(SccmCaptureRoot { + role: SccmRole::Client, + path, + }); + } + } + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + environment.issues.push(SccmDiscoveryIssue { + code: SccmDiscoveryIssueCode::RegistryAccessDenied, + role: Some(SccmRole::Client), + }); + } + Err(_) => {} + } + + let mut server_install_root = None; + match hklm.open_subkey(SITE_SERVER_KEY) { + Ok(key) => { + environment.roles.push(SccmDetectedRole { + role: SccmRole::SiteServer, + basis: SccmDiscoveryBasis::Registry, + }); + environment.private_site_code = key.get_value::("Site Code").ok(); + if let Ok(path) = key.get_value::("Installation Directory") { + server_install_root = Some(PathBuf::from(&path)); + environment.roots.push(SccmCaptureRoot { + role: SccmRole::SiteServer, + path: PathBuf::from(path).join("Logs"), + }); + } + } + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + environment.issues.push(SccmDiscoveryIssue { + code: SccmDiscoveryIssueCode::RegistryAccessDenied, + role: Some(SccmRole::SiteServer), + }); + } + Err(_) => {} + } + + for (key_name, role) in [ + (r"SOFTWARE\Microsoft\SMS\MP", SccmRole::ManagementPoint), + (r"SOFTWARE\Microsoft\SMS\DP", SccmRole::DistributionPoint), + (r"SOFTWARE\Microsoft\SMS\SUP", SccmRole::SoftwareUpdatePoint), + (r"SOFTWARE\Microsoft\SMS\WSUS", SccmRole::WsUs), + (r"SOFTWARE\Microsoft\SMS\Providers", SccmRole::Provider), + ( + r"SOFTWARE\Microsoft\SMS\AdminService", + SccmRole::AdminService, + ), + ] { + match hklm.open_subkey(key_name) { + Ok(key) => { + let path = key + .get_value::("Log Directory") + .ok() + .map(PathBuf::from) + .or_else(|| server_install_root.as_ref().map(|path| path.join("Logs"))) + .or_else(|| default_role_root(&role)); + environment.roles.push(SccmDetectedRole { + role: role.clone(), + basis: SccmDiscoveryBasis::Registry, + }); + if let Some(path) = path { + environment.roots.push(SccmCaptureRoot { role, path }); + } + } + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + environment.issues.push(SccmDiscoveryIssue { + code: SccmDiscoveryIssueCode::RegistryAccessDenied, + role: Some(role), + }); + } + _ => {} + } + } + + match Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + FIXED_ROLE_QUERY, + ]) + .output() + { + Ok(output) if output.status.success() => { + let client_root = client_log_root(); + apply_cim_service_facts( + &mut environment, + &output.stdout, + server_install_root.as_deref(), + client_root.as_deref(), + ); + } + Ok(output) if !output.status.success() => { + environment.issues.push(SccmDiscoveryIssue { + code: SccmDiscoveryIssueCode::CimAccessDenied, + role: None, + }); + } + Err(_) => environment.issues.push(SccmDiscoveryIssue { + code: SccmDiscoveryIssueCode::DiscoveryFailed, + role: None, + }), + _ => {} + } + + Ok(environment) +} + +#[cfg(target_os = "windows")] +fn client_log_root() -> Option { + std::env::var_os("WINDIR") + .map(std::path::PathBuf::from) + .map(|path| path.join("CCM").join("Logs")) +} + +#[cfg(any(test, target_os = "windows"))] +fn apply_cim_service_facts( + environment: &mut PrivateSccmEnvironment, + output: &[u8], + server_install_root: Option<&std::path::Path>, + allow_listed_client_root: Option<&std::path::Path>, +) { + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum ServiceNames { + One(String), + Many(Vec), + } + + let Ok(names) = serde_json::from_slice::(output) else { + return; + }; + let names = match names { + ServiceNames::One(name) => vec![name], + ServiceNames::Many(names) => names, + }; + + for service_name in names.into_iter().collect::>() { + let Some(role) = role_for_cim_service(&service_name) else { + continue; + }; + environment.roles.push(SccmDetectedRole { + role: role.clone(), + basis: SccmDiscoveryBasis::Cim, + }); + let path = match role { + SccmRole::Client => allow_listed_client_root.map(std::path::Path::to_path_buf), + _ => server_install_root + .map(|path| path.join("Logs")) + .or_else(|| default_role_root(&role)), + }; + if let Some(path) = path { + environment.roots.push(SccmCaptureRoot { role, path }); + } + } +} + +#[cfg(any(test, target_os = "windows"))] +fn role_for_cim_service(service_name: &str) -> Option { + match service_name { + "CcmExec" => Some(SccmRole::Client), + "SMS_EXECUTIVE" => Some(SccmRole::SiteServer), + "SMS_ADMIN_SERVICE" => Some(SccmRole::AdminService), + "WSUSService" => Some(SccmRole::WsUs), + _ => None, + } +} + +#[cfg(any(test, target_os = "windows"))] +fn default_role_root(role: &SccmRole) -> Option { + let system_drive = std::env::var_os("SystemDrive") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\")); + let program_files = std::env::var_os("ProgramFiles").map(std::path::PathBuf::from); + match role { + SccmRole::ManagementPoint | SccmRole::AdminService => { + Some(system_drive.join("SMS_CCM").join("Logs")) + } + SccmRole::DistributionPoint => Some(system_drive.join("SMS_DP$").join("sms").join("logs")), + SccmRole::SoftwareUpdatePoint | SccmRole::Provider | SccmRole::SiteServer => { + program_files.map(|path| path.join("Microsoft Configuration Manager").join("Logs")) + } + SccmRole::WsUs => program_files.map(|path| path.join("Update Services").join("LogFiles")), + SccmRole::Client | SccmRole::Unknown(_) => None, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn ccmexec_service_without_setup_registry_fact_admits_client_and_fixed_root() { + let client_root = PathBuf::from(r"C:\Windows\CCM\Logs"); + let mut environment = PrivateSccmEnvironment { + supported: true, + ..PrivateSccmEnvironment::default() + }; + + apply_cim_service_facts(&mut environment, br#""CcmExec""#, None, Some(&client_root)); + + assert_eq!( + environment.roles, + vec![SccmDetectedRole { + role: SccmRole::Client, + basis: SccmDiscoveryBasis::Cim, + }] + ); + assert_eq!( + environment.roots, + vec![SccmCaptureRoot { + role: SccmRole::Client, + path: client_root, + }] + ); + } + + #[test] + fn similarly_named_or_untrusted_service_output_does_not_admit_client() { + for output in [ + br#""CcmExecHelper""#.as_slice(), + br#""CCMEXEC""#.as_slice(), + br#"" CcmExec ""#.as_slice(), + br#"["NotCcmExec", "CcmExecAgent"]"#.as_slice(), + br#"{"Name":"CcmExec"}"#.as_slice(), + b"CcmExec".as_slice(), + ] { + let mut environment = PrivateSccmEnvironment::default(); + let client_root = PathBuf::from(r"C:\Windows\CCM\Logs"); + apply_cim_service_facts(&mut environment, output, None, Some(&client_root)); + + assert!(environment.roles.is_empty()); + assert!(environment.roots.is_empty()); + } + } + + #[test] + fn ccmexec_service_never_supplies_or_invents_a_client_root() { + let mut environment = PrivateSccmEnvironment::default(); + + apply_cim_service_facts(&mut environment, br#""CcmExec""#, None, None); + + assert_eq!(environment.roles[0].role, SccmRole::Client); + assert!(environment.roots.is_empty()); + } + + #[test] + fn fixed_cim_query_requests_only_allow_listed_service_names() { + assert!(FIXED_ROLE_QUERY.contains("Name='CcmExec'")); + assert!(FIXED_ROLE_QUERY.contains("Select-Object -ExpandProperty Name")); + assert!(!FIXED_ROLE_QUERY.contains("PathName")); + assert!(!FIXED_ROLE_QUERY.contains("StartName")); + } +} diff --git a/src-tauri/src/sccm/collector/engine.rs b/src-tauri/src/sccm/collector/engine.rs new file mode 100644 index 000000000..ae80dc9f2 --- /dev/null +++ b/src-tauri/src/sccm/collector/engine.rs @@ -0,0 +1,1002 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use chrono::{SecondsFormat, Utc}; +use cmtraceopen_parser::sccm::server::windows::{ + declared_server_source_catalog, SccmServerSourceKind, SccmServerSourceSpec, +}; +use cmtraceopen_parser::sccm::{classify_artifact_name, SccmRotation}; +use sha2::{Digest, Sha256}; + +use crate::sccm::contract::{logical_artifact_ids_for_basename, sha256_bytes}; +use crate::sccm::contract::{ + SccmCaptureLimitKind, SccmManifestCoverageScope, SccmManifestSourceState, +}; +use crate::sccm::private_fs::is_reparse_point; +use crate::sccm::{SccmCoverageState, SccmRole}; + +use super::client_manifest::{ + client_relative_path, write_and_validate_client_manifest, CapturedClientArtifact, + ClientCoverageRecord, +}; +use super::discovery::normalized_private_environment; +use super::server_manifest::{ + server_relative_path, write_and_validate_server_manifest, CapturedServerArtifact, + ServerCoverageLimit, ServerCoverageRecord, +}; +use super::{ + role_key, sort_sources, SccmCaptureResult, SccmCollectorError, SccmDiscoveryProvider, + SccmRotationCategory, SccmSourceDetailCode, SccmSourceStatus, +}; + +pub const MAX_FRAGMENTS_PER_SOURCE: usize = 8; +pub const MAX_BYTES_PER_SOURCE: u64 = 16 * 1024 * 1024; + +#[derive(Debug, Clone)] +struct Candidate { + role: SccmRole, + source_id: String, + workflow_subject_role: Option, + source_kind: Option<&'static str>, + root_handle: String, + canonical_basename: String, + physical_basename: String, + rotation: SccmRotation, + source_path: PathBuf, + approved_root: PathBuf, + relative_path: String, +} + +pub fn capture_environment( + provider: &dyn SccmDiscoveryProvider, + bundle_root: &Path, +) -> Result { + let environment = discover_capture_environment(provider)?; + capture_discovered_environment(environment, bundle_root) +} + +pub(crate) fn discover_capture_environment( + provider: &dyn SccmDiscoveryProvider, +) -> Result { + let environment = normalized_private_environment(provider) + .map_err(|_| SccmCollectorError::DiscoveryFailed)?; + if environment.roles.is_empty() { + return Err(SccmCollectorError::NoRolesDetected); + } + Ok(environment) +} + +pub(crate) fn capture_discovered_environment( + environment: super::PrivateSccmEnvironment, + bundle_root: &Path, +) -> Result { + if environment.roles.is_empty() { + return Err(SccmCollectorError::NoRolesDetected); + } + prepare_private_bundle_root(bundle_root)?; + let captured_at_utc = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let mut statuses = Vec::new(); + let mut candidates = Vec::new(); + let mut client_coverage = Vec::new(); + let mut server_coverage = Vec::new(); + + for root in &environment.roots { + enumerate_root( + root, + &mut candidates, + &mut statuses, + &mut client_coverage, + &mut server_coverage, + )?; + } + candidates.sort_by(|left, right| { + ( + role_key(&left.role), + left.source_id.as_str(), + left.root_handle.as_str(), + rotation_key(&left.rotation), + left.physical_basename.to_ascii_lowercase(), + ) + .cmp(&( + role_key(&right.role), + right.source_id.as_str(), + right.root_handle.as_str(), + rotation_key(&right.rotation), + right.physical_basename.to_ascii_lowercase(), + )) + }); + preflight_destinations(bundle_root, &candidates)?; + + let mut source_totals = BTreeMap::<(String, String, String), (usize, u64)>::new(); + let mut client_artifacts = Vec::new(); + let mut server_artifacts = Vec::new(); + let mut retained_bytes = 0_u64; + + for candidate in candidates { + let source_key = ( + role_key(&candidate.role), + candidate.root_handle.clone(), + candidate.source_id.clone(), + ); + let totals = source_totals.entry(source_key).or_default(); + if totals.0 >= MAX_FRAGMENTS_PER_SOURCE { + statuses.push(status_for( + &candidate, + SccmCoverageState::Capped, + 0, + Some(SccmSourceDetailCode::FileLimitExceeded), + )); + record_candidate_gap( + &candidate, + SccmManifestSourceState::Capped, + Some(SccmCaptureLimitKind::FileCount), + fs::metadata(&candidate.source_path) + .map(|value| value.len()) + .unwrap_or(0), + &mut client_coverage, + &mut server_coverage, + ); + continue; + } + let remaining = MAX_BYTES_PER_SOURCE.saturating_sub(totals.1); + if remaining == 0 { + statuses.push(status_for( + &candidate, + SccmCoverageState::Capped, + 0, + Some(SccmSourceDetailCode::ByteLimitExceeded), + )); + record_candidate_gap( + &candidate, + SccmManifestSourceState::Capped, + Some(SccmCaptureLimitKind::Bytes), + fs::metadata(&candidate.source_path) + .map(|value| value.len()) + .unwrap_or(0), + &mut client_coverage, + &mut server_coverage, + ); + continue; + } + let outcome = match copy_candidate(bundle_root, &candidate, remaining) { + Ok(outcome) => outcome, + Err(CopyFailure::AccessDenied) => { + statuses.push(status_for( + &candidate, + SccmCoverageState::AccessDenied, + 0, + Some(SccmSourceDetailCode::AccessDenied), + )); + record_candidate_gap( + &candidate, + SccmManifestSourceState::AccessDenied, + None, + 0, + &mut client_coverage, + &mut server_coverage, + ); + continue; + } + Err(CopyFailure::Unsafe) => { + statuses.push(status_for( + &candidate, + SccmCoverageState::Skipped, + 0, + Some(SccmSourceDetailCode::UnsafePath), + )); + record_candidate_gap( + &candidate, + SccmManifestSourceState::Skipped, + None, + 0, + &mut client_coverage, + &mut server_coverage, + ); + continue; + } + Err(CopyFailure::Failed) => { + statuses.push(status_for( + &candidate, + SccmCoverageState::ParseFailed, + 0, + Some(SccmSourceDetailCode::ReadFailed), + )); + record_candidate_gap( + &candidate, + SccmManifestSourceState::FailedUnknownDetail, + None, + 0, + &mut client_coverage, + &mut server_coverage, + ); + continue; + } + }; + totals.0 += 1; + totals.1 = totals.1.saturating_add(outcome.bytes.len() as u64); + retained_bytes = retained_bytes.saturating_add(outcome.bytes.len() as u64); + let state = if outcome.capped { + SccmCoverageState::Capped + } else { + SccmCoverageState::Captured + }; + statuses.push(status_for( + &candidate, + state.clone(), + outcome.bytes.len() as u64, + outcome + .capped + .then_some(SccmSourceDetailCode::ByteLimitExceeded), + )); + if candidate.role == SccmRole::Client { + client_artifacts.push(CapturedClientArtifact { + root_handle: candidate.root_handle, + basename: candidate.canonical_basename, + rotation: candidate.rotation, + state: if outcome.capped { + crate::sccm::SccmManifestSourceState::Capped + } else { + crate::sccm::SccmManifestSourceState::Captured + }, + relative_path: candidate.relative_path, + retained_bytes: outcome.bytes.len() as u64, + content_sha256: outcome.sha256, + capped: outcome.capped, + }); + } else { + server_artifacts.push(CapturedServerArtifact { + role: candidate.role, + workflow_subject_role: candidate.workflow_subject_role, + source_id: candidate.source_id, + source_kind: candidate + .source_kind + .ok_or(SccmCollectorError::CaptureFailed)?, + root_handle: candidate.root_handle, + basename: candidate.physical_basename, + rotation: candidate.rotation, + relative_path: candidate.relative_path, + retained_bytes: outcome.bytes.len() as u64, + bytes: outcome.bytes, + capped: outcome.capped, + }); + } + } + + if environment + .roles + .iter() + .any(|fact| fact.role == SccmRole::Client) + { + write_and_validate_client_manifest( + bundle_root, + &captured_at_utc, + environment.configmgr_version.as_deref(), + client_artifacts, + client_coverage, + )?; + } + + let mut server_roles = environment + .roles + .iter() + .map(|fact| fact.role.clone()) + .filter(|role| *role != SccmRole::Client) + .collect::>(); + server_roles.sort_by_key(role_key); + server_roles.dedup(); + write_and_validate_server_manifest( + bundle_root, + &captured_at_utc, + &server_roles, + environment.private_host.as_deref(), + environment.private_site_code.as_deref(), + server_artifacts, + server_coverage, + )?; + + sort_sources(&mut statuses); + let mut roles = environment + .roles + .into_iter() + .map(|fact| fact.role) + .collect::>(); + roles.sort_by_key(role_key); + roles.dedup(); + let artifact_count = statuses + .iter() + .filter(|status| status.retained_bytes > 0 || status.state == SccmCoverageState::Captured) + .count(); + Ok(SccmCaptureResult { + bundle_root: bundle_root.to_string_lossy().into_owned(), + captured_at_utc, + roles, + sources: statuses, + artifact_count, + retained_bytes, + }) +} + +fn prepare_private_bundle_root(bundle_root: &Path) -> Result<(), SccmCollectorError> { + fs::create_dir(bundle_root).map_err(|_| SccmCollectorError::DestinationUnavailable)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(bundle_root, fs::Permissions::from_mode(0o700)) + .map_err(|_| SccmCollectorError::DestinationUnavailable)?; + } + Ok(()) +} + +fn enumerate_root( + root: &super::SccmCaptureRoot, + candidates: &mut Vec, + statuses: &mut Vec, + client_coverage: &mut Vec, + server_coverage: &mut Vec, +) -> Result<(), SccmCollectorError> { + let root_handle = root_handle_for(&root.path); + let canonical_root = match root.path.canonicalize() { + Ok(path) => path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + record_expected_root_coverage( + root, + &root_handle, + SccmManifestSourceState::Absent, + SccmManifestCoverageScope::Source, + statuses, + client_coverage, + server_coverage, + ); + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + record_expected_root_coverage( + root, + &root_handle, + SccmManifestSourceState::AccessDenied, + SccmManifestCoverageScope::RootEnumeration, + statuses, + client_coverage, + server_coverage, + ); + return Ok(()); + } + Err(_) => { + record_expected_root_coverage( + root, + &root_handle, + SccmManifestSourceState::FailedUnknownDetail, + SccmManifestCoverageScope::RootEnumeration, + statuses, + client_coverage, + server_coverage, + ); + return Ok(()); + } + }; + let root_metadata = + fs::symlink_metadata(&root.path).map_err(|_| SccmCollectorError::CaptureFailed)?; + if root_metadata.file_type().is_symlink() || is_reparse_point(&root_metadata) { + record_expected_root_coverage( + root, + &root_handle, + SccmManifestSourceState::UnsafePath, + SccmManifestCoverageScope::Source, + statuses, + client_coverage, + server_coverage, + ); + return Ok(()); + } + let entries = match fs::read_dir(&canonical_root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + record_expected_root_coverage( + root, + &root_handle, + SccmManifestSourceState::AccessDenied, + SccmManifestCoverageScope::RootEnumeration, + statuses, + client_coverage, + server_coverage, + ); + return Ok(()); + } + Err(_) => { + record_expected_root_coverage( + root, + &root_handle, + SccmManifestSourceState::FailedUnknownDetail, + SccmManifestCoverageScope::RootEnumeration, + statuses, + client_coverage, + server_coverage, + ); + return Ok(()); + } + }; + let mut observed = BTreeSet::new(); + for entry in entries { + let Ok(entry) = entry else { + continue; + }; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let metadata = match fs::symlink_metadata(entry.path()) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + if let Some(expected) = expected_source_for_name(&root.role, &name) { + observed.insert(expected.identity()); + statuses.push(SccmSourceStatus { + role: root.role.clone(), + source_id: expected.source_id.clone(), + rotation: SccmRotationCategory::Unknown, + state: SccmCoverageState::Skipped, + retained_bytes: 0, + detail_code: Some(SccmSourceDetailCode::UnsafePath), + }); + record_expected_coverage( + root, + &root_handle, + expected, + SccmManifestSourceState::Skipped, + SccmManifestCoverageScope::Source, + client_coverage, + server_coverage, + ); + } + continue; + } + if !metadata.is_file() { + continue; + } + if let Some(candidate) = classify_candidate(root, &root_handle, entry.path(), &name)? { + observed.insert(expected_identity_for_candidate(&candidate)); + candidates.push(candidate); + } else if let Some(expected) = expected_source_for_name(&root.role, &name) { + observed.insert(expected.identity()); + statuses.push(SccmSourceStatus { + role: root.role.clone(), + source_id: expected.source_id.clone(), + rotation: SccmRotationCategory::Unknown, + state: SccmCoverageState::Unsupported, + retained_bytes: 0, + detail_code: Some(SccmSourceDetailCode::MalformedRotation), + }); + record_expected_coverage( + root, + &root_handle, + expected, + SccmManifestSourceState::Unsupported, + SccmManifestCoverageScope::Source, + client_coverage, + server_coverage, + ); + } + } + for expected in expected_sources(&root.role) { + if observed.insert(expected.identity()) { + statuses.push(SccmSourceStatus { + role: root.role.clone(), + source_id: expected.source_id.clone(), + rotation: SccmRotationCategory::Current, + state: SccmCoverageState::Absent, + retained_bytes: 0, + detail_code: None, + }); + record_expected_coverage( + root, + &root_handle, + expected, + SccmManifestSourceState::Absent, + SccmManifestCoverageScope::Source, + client_coverage, + server_coverage, + ); + } + } + Ok(()) +} + +fn classify_candidate( + root: &super::SccmCaptureRoot, + root_handle: &str, + source_path: PathBuf, + name: &str, +) -> Result, SccmCollectorError> { + let classified = classify_artifact_name(name, root.role.clone()); + if root.role == SccmRole::Client { + if !classified.supported_for_diagnosis { + return Ok(None); + } + let memberships = logical_artifact_ids_for_basename(&classified.basename); + if memberships.is_empty() { + return Ok(None); + } + let source_id = memberships[0].clone(); + let relative_path = + client_relative_path(root_handle, &classified.basename, &classified.rotation); + return Ok(Some(Candidate { + role: SccmRole::Client, + source_id, + workflow_subject_role: None, + source_kind: None, + root_handle: root_handle.to_owned(), + canonical_basename: classified.basename, + physical_basename: name.to_owned(), + rotation: classified.rotation, + source_path, + approved_root: root + .path + .canonicalize() + .map_err(|_| SccmCollectorError::CaptureFailed)?, + relative_path, + })); + } + + let Some(spec) = matching_server_spec(root.role.clone(), name) else { + return Ok(None); + }; + let rotation = if spec.source_kind == SccmServerSourceKind::ProfileDefined { + SccmRotation::Current + } else { + classified.rotation + }; + let source_kind = source_kind_name(spec.source_kind); + let relative_path = server_relative_path( + &root.role, + spec.workflow_subject_role.as_ref(), + spec.source_id, + root_handle, + name, + &rotation, + )?; + Ok(Some(Candidate { + role: root.role.clone(), + source_id: spec.source_id.to_owned(), + workflow_subject_role: spec.workflow_subject_role.clone(), + source_kind: Some(source_kind), + root_handle: root_handle.to_owned(), + canonical_basename: if spec.source_kind == SccmServerSourceKind::ProfileDefined { + name.to_owned() + } else { + classified.basename + }, + physical_basename: name.to_owned(), + rotation, + source_path, + approved_root: root + .path + .canonicalize() + .map_err(|_| SccmCollectorError::CaptureFailed)?, + relative_path, + })) +} + +fn matching_server_spec(role: SccmRole, name: &str) -> Option<&'static SccmServerSourceSpec> { + let classified = classify_artifact_name(name, role.clone()); + declared_server_source_catalog().iter().find(|spec| { + spec.producer_role == role + && match spec.source_kind { + SccmServerSourceKind::CcmLog => { + classified.supported_for_diagnosis + && spec + .logical_names + .iter() + .any(|logical| logical.eq_ignore_ascii_case(&classified.logical_name)) + } + SccmServerSourceKind::ProfileDefined => spec + .explicit_basename + .is_some_and(|basename| basename.eq_ignore_ascii_case(name)), + SccmServerSourceKind::IisW3c | SccmServerSourceKind::StructuredSupplement => false, + } + }) +} + +#[derive(Clone)] +struct ExpectedSource { + source_id: String, + basename: String, + workflow_subject_role: Option, + source_kind: Option<&'static str>, +} + +impl ExpectedSource { + fn identity(&self) -> String { + format!("{}\0{}", self.source_id, self.basename.to_ascii_lowercase()) + } +} + +fn expected_sources(role: &SccmRole) -> Vec { + let mut expected = if *role == SccmRole::Client { + let mut basenames = cmtraceopen_parser::sccm::declared_client_source_groups() + .into_iter() + .flat_map(|group| group.accepted_basenames) + .collect::>(); + basenames.sort_by_key(|value| value.to_ascii_lowercase()); + basenames.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); + basenames + .into_iter() + .map(|basename| ExpectedSource { + source_id: logical_artifact_ids_for_basename(&basename) + .into_iter() + .next() + .unwrap_or_else(|| "client-source".to_owned()), + basename, + workflow_subject_role: None, + source_kind: None, + }) + .collect::>() + } else { + declared_server_source_catalog() + .iter() + .filter(|spec| spec.producer_role == *role) + .flat_map(|spec| { + let basenames = match spec.source_kind { + SccmServerSourceKind::CcmLog => spec + .logical_names + .iter() + .filter_map(|logical| { + cmtraceopen_parser::sccm::declared_source_catalog() + .into_iter() + .find(|entry| { + entry.role == *role + && entry.logical_name.eq_ignore_ascii_case(logical) + }) + .map(|entry| entry.basename) + }) + .collect::>(), + SccmServerSourceKind::ProfileDefined => spec + .explicit_basename + .into_iter() + .map(str::to_owned) + .collect(), + // These supplemental contracts have no catalog basename; + // a separate configured root/profile must name them before + // collection can make a source claim. + SccmServerSourceKind::IisW3c | SccmServerSourceKind::StructuredSupplement => { + Vec::new() + } + }; + basenames.into_iter().map(move |basename| ExpectedSource { + source_id: spec.source_id.to_owned(), + basename, + workflow_subject_role: spec.workflow_subject_role.clone(), + source_kind: Some(source_kind_name(spec.source_kind)), + }) + }) + .collect::>() + }; + expected.sort_by_key(ExpectedSource::identity); + expected.dedup_by(|left, right| left.identity() == right.identity()); + expected +} + +fn expected_source_for_name(role: &SccmRole, name: &str) -> Option { + let classified = classify_artifact_name(name, role.clone()); + expected_sources(role).into_iter().find(|expected| { + expected.basename.eq_ignore_ascii_case(&classified.basename) + || name + .to_ascii_lowercase() + .starts_with(&expected.basename.to_ascii_lowercase()) + }) +} + +fn expected_identity_for_candidate(candidate: &Candidate) -> String { + format!( + "{}\0{}", + candidate.source_id, + candidate.canonical_basename.to_ascii_lowercase() + ) +} + +fn source_kind_name(kind: SccmServerSourceKind) -> &'static str { + match kind { + SccmServerSourceKind::CcmLog => "ccmLog", + SccmServerSourceKind::IisW3c => "iisW3c", + SccmServerSourceKind::StructuredSupplement => "structuredSupplement", + SccmServerSourceKind::ProfileDefined => "profileDefined", + } +} + +fn record_expected_root_coverage( + root: &super::SccmCaptureRoot, + root_handle: &str, + state: SccmManifestSourceState, + scope: SccmManifestCoverageScope, + statuses: &mut Vec, + client_coverage: &mut Vec, + server_coverage: &mut Vec, +) { + for expected in expected_sources(&root.role) { + statuses.push(SccmSourceStatus { + role: root.role.clone(), + source_id: expected.source_id.clone(), + rotation: SccmRotationCategory::Current, + state: public_coverage(state), + retained_bytes: 0, + detail_code: detail_for_manifest_state(state), + }); + record_expected_coverage( + root, + root_handle, + expected, + state, + scope, + client_coverage, + server_coverage, + ); + } +} + +fn record_expected_coverage( + root: &super::SccmCaptureRoot, + root_handle: &str, + expected: ExpectedSource, + state: SccmManifestSourceState, + scope: SccmManifestCoverageScope, + client_coverage: &mut Vec, + server_coverage: &mut Vec, +) { + if root.role == SccmRole::Client { + client_coverage.push(ClientCoverageRecord { + root_handle: root_handle.to_owned(), + basename: expected.basename, + rotation: SccmRotation::Current, + state, + coverage_scope: scope, + gap_limit: None, + source_bytes: 0, + }); + } else { + server_coverage.push(ServerCoverageRecord { + role: root.role.clone(), + workflow_subject_role: expected.workflow_subject_role, + source_id: expected.source_id, + source_kind: expected.source_kind.unwrap_or("ccmLog"), + root_handle: root_handle.to_owned(), + basename: expected.basename, + rotation: SccmRotation::Current, + state: server_coverage_state(state), + collection_limit: None, + }); + } +} + +fn record_candidate_gap( + candidate: &Candidate, + state: SccmManifestSourceState, + gap_limit: Option, + source_bytes: u64, + client_coverage: &mut Vec, + server_coverage: &mut Vec, +) { + if candidate.role == SccmRole::Client { + client_coverage.push(ClientCoverageRecord { + root_handle: candidate.root_handle.clone(), + basename: candidate.canonical_basename.clone(), + rotation: candidate.rotation.clone(), + state, + coverage_scope: SccmManifestCoverageScope::Source, + gap_limit, + source_bytes, + }); + } else { + server_coverage.push(ServerCoverageRecord { + role: candidate.role.clone(), + workflow_subject_role: candidate.workflow_subject_role.clone(), + source_id: candidate.source_id.clone(), + source_kind: candidate.source_kind.unwrap_or("ccmLog"), + root_handle: candidate.root_handle.clone(), + basename: candidate.physical_basename.clone(), + rotation: candidate.rotation.clone(), + state: server_coverage_state(state), + collection_limit: (state == SccmManifestSourceState::Capped).then_some( + ServerCoverageLimit { + byte_limit: MAX_BYTES_PER_SOURCE, + file_limit: MAX_FRAGMENTS_PER_SOURCE, + }, + ), + }); + } +} + +fn public_coverage(state: SccmManifestSourceState) -> SccmCoverageState { + match state { + SccmManifestSourceState::Captured => SccmCoverageState::Captured, + SccmManifestSourceState::Absent => SccmCoverageState::Absent, + SccmManifestSourceState::AccessDenied => SccmCoverageState::AccessDenied, + SccmManifestSourceState::Capped => SccmCoverageState::Capped, + SccmManifestSourceState::Skipped => SccmCoverageState::Skipped, + SccmManifestSourceState::ParseFailed | SccmManifestSourceState::FailedUnknownDetail => { + SccmCoverageState::ParseFailed + } + SccmManifestSourceState::Unsupported | SccmManifestSourceState::UnsafePath => { + SccmCoverageState::Unsupported + } + } +} + +fn server_coverage_state(state: SccmManifestSourceState) -> SccmCoverageState { + match state { + SccmManifestSourceState::Captured => SccmCoverageState::Captured, + SccmManifestSourceState::Absent => SccmCoverageState::Absent, + SccmManifestSourceState::AccessDenied => SccmCoverageState::AccessDenied, + SccmManifestSourceState::Capped => SccmCoverageState::Capped, + SccmManifestSourceState::Skipped => SccmCoverageState::Skipped, + SccmManifestSourceState::ParseFailed | SccmManifestSourceState::FailedUnknownDetail => { + SccmCoverageState::ParseFailed + } + SccmManifestSourceState::Unsupported | SccmManifestSourceState::UnsafePath => { + SccmCoverageState::Unsupported + } + } +} + +fn detail_for_manifest_state(state: SccmManifestSourceState) -> Option { + match state { + SccmManifestSourceState::AccessDenied => Some(SccmSourceDetailCode::AccessDenied), + SccmManifestSourceState::UnsafePath => Some(SccmSourceDetailCode::UnsafePath), + SccmManifestSourceState::FailedUnknownDetail | SccmManifestSourceState::ParseFailed => { + Some(SccmSourceDetailCode::ReadFailed) + } + _ => None, + } +} + +fn root_handle_for(path: &Path) -> String { + let identity = path.canonicalize().unwrap_or_else(|_| path.to_owned()); + format!( + "root-{}", + sha256_bytes(identity.to_string_lossy().as_bytes()) + ) +} + +fn preflight_destinations( + bundle_root: &Path, + candidates: &[Candidate], +) -> Result<(), SccmCollectorError> { + let mut destinations = BTreeSet::new(); + for candidate in candidates { + if !destinations.insert(candidate.relative_path.to_ascii_lowercase()) + || bundle_root.join(&candidate.relative_path).exists() + { + return Err(SccmCollectorError::DestinationUnavailable); + } + } + Ok(()) +} + +struct CopyOutcome { + bytes: Vec, + sha256: String, + capped: bool, +} + +enum CopyFailure { + AccessDenied, + Unsafe, + Failed, +} + +fn copy_candidate( + bundle_root: &Path, + candidate: &Candidate, + limit: u64, +) -> Result { + let canonical_source = candidate + .source_path + .canonicalize() + .map_err(map_copy_error)?; + let canonical_parent = candidate + .source_path + .parent() + .ok_or(CopyFailure::Unsafe)? + .canonicalize() + .map_err(map_copy_error)?; + if canonical_source.parent() != Some(canonical_parent.as_path()) + || canonical_parent != candidate.approved_root + || !canonical_source.starts_with(&candidate.approved_root) + { + return Err(CopyFailure::Unsafe); + } + let mut input = open_source_no_follow(&candidate.source_path).map_err(map_copy_error)?; + let metadata = input.metadata().map_err(map_copy_error)?; + if !metadata.is_file() || is_reparse_point(&metadata) { + return Err(CopyFailure::Unsafe); + } + let capped = metadata.len() > limit; + let read_limit = metadata.len().min(limit); + let capacity = usize::try_from(read_limit).map_err(|_| CopyFailure::Failed)?; + let mut bytes = Vec::with_capacity(capacity); + Read::by_ref(&mut input) + .take(read_limit) + .read_to_end(&mut bytes) + .map_err(map_copy_error)?; + if bytes.len() as u64 != read_limit { + return Err(CopyFailure::Failed); + } + let destination = bundle_root.join(&candidate.relative_path); + fs::create_dir_all(destination.parent().ok_or(CopyFailure::Unsafe)?).map_err(map_copy_error)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .map_err(map_copy_error)?; + output.write_all(&bytes).map_err(map_copy_error)?; + output.sync_all().map_err(map_copy_error)?; + Ok(CopyOutcome { + sha256: Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + bytes, + capped, + }) +} + +fn open_source_no_follow(path: &Path) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); + } + options.open(path) +} + +fn map_copy_error(error: std::io::Error) -> CopyFailure { + if error.kind() == std::io::ErrorKind::PermissionDenied { + CopyFailure::AccessDenied + } else { + CopyFailure::Failed + } +} + +fn status_for( + candidate: &Candidate, + state: SccmCoverageState, + retained_bytes: u64, + detail_code: Option, +) -> SccmSourceStatus { + SccmSourceStatus { + role: candidate.role.clone(), + source_id: candidate.source_id.clone(), + rotation: rotation_category(&candidate.rotation), + state, + retained_bytes, + detail_code, + } +} + +fn rotation_category(rotation: &SccmRotation) -> SccmRotationCategory { + match rotation { + SccmRotation::Current => SccmRotationCategory::Current, + SccmRotation::LoUnderscore => SccmRotationCategory::LoUnderscore, + SccmRotation::Numbered(_) => SccmRotationCategory::Numbered, + SccmRotation::Timestamped(_) => SccmRotationCategory::Timestamped, + SccmRotation::Unknown(_) => SccmRotationCategory::Unknown, + } +} + +fn rotation_key(rotation: &SccmRotation) -> String { + match rotation { + SccmRotation::Current => "0-current".to_owned(), + SccmRotation::LoUnderscore => "1-lo".to_owned(), + SccmRotation::Numbered(value) => format!("2-{value:010}"), + SccmRotation::Timestamped(value) => format!("3-{value}"), + SccmRotation::Unknown(_) => "4-unknown".to_owned(), + } +} diff --git a/src-tauri/src/sccm/collector/mod.rs b/src-tauri/src/sccm/collector/mod.rs new file mode 100644 index 000000000..4830ecc25 --- /dev/null +++ b/src-tauri/src/sccm/collector/mod.rs @@ -0,0 +1,217 @@ +//! Privacy-preserving native SCCM discovery and collection. + +mod client_manifest; +mod discovery; +mod engine; +mod server_manifest; + +use std::fmt; +use std::path::PathBuf; + +use serde::Serialize; + +use super::{SccmCoverageState, SccmRole}; + +pub use discovery::{discover_environment, discover_environment_with, NativeDiscoveryProvider}; +pub(crate) use engine::{capture_discovered_environment, discover_capture_environment}; +pub use engine::{capture_environment, MAX_BYTES_PER_SOURCE, MAX_FRAGMENTS_PER_SOURCE}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDiscoveryBasis { + Registry, + Service, + Cim, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmRotationCategory { + Current, + LoUnderscore, + Numbered, + Timestamped, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSourceDetailCode { + AccessDenied, + ByteLimitExceeded, + FileLimitExceeded, + MalformedRotation, + ReadFailed, + UnsafePath, + UnsupportedPlatform, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDiscoveryIssueCode { + UnsupportedPlatform, + RegistryAccessDenied, + CimAccessDenied, + DiscoveryFailed, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDetectedRole { + pub role: SccmRole, + pub basis: SccmDiscoveryBasis, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSourceStatus { + pub role: SccmRole, + pub source_id: String, + pub rotation: SccmRotationCategory, + pub state: SccmCoverageState, + pub retained_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDiscoveryIssue { + pub code: SccmDiscoveryIssueCode, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmEnvironmentDiscovery { + pub supported: bool, + pub configmgr_version: Option, + pub roles: Vec, + pub sources: Vec, + pub issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmCaptureResult { + pub bundle_root: String, + pub captured_at_utc: String, + pub roles: Vec, + pub sources: Vec, + pub artifact_count: usize, + pub retained_bytes: u64, +} + +/// Private discovery facts. These values are deliberately never serialized +/// across the Tauri boundary. +#[derive(Debug, Clone, Default)] +pub struct PrivateSccmEnvironment { + pub supported: bool, + pub configmgr_version: Option, + pub roles: Vec, + pub roots: Vec, + pub issues: Vec, + pub private_host: Option, + pub private_site_code: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmCaptureRoot { + pub role: SccmRole, + pub path: PathBuf, +} + +pub trait SccmDiscoveryProvider { + fn discover(&self) -> Result; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SccmDiscoveryFailure { + Failed, +} + +impl fmt::Display for SccmDiscoveryFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SCCM discovery failed") + } +} + +impl std::error::Error for SccmDiscoveryFailure {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SccmCollectorError { + DiscoveryFailed, + NoRolesDetected, + DestinationUnavailable, + CaptureFailed, + ManifestValidationFailed, +} + +impl SccmCollectorError { + pub fn code(self) -> &'static str { + match self { + Self::DiscoveryFailed => "discoveryFailed", + Self::NoRolesDetected => "noRolesDetected", + Self::DestinationUnavailable => "destinationUnavailable", + Self::CaptureFailed => "captureFailed", + Self::ManifestValidationFailed => "manifestValidationFailed", + } + } +} + +impl fmt::Display for SccmCollectorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.code()) + } +} + +impl std::error::Error for SccmCollectorError {} + +pub(crate) fn role_key(role: &SccmRole) -> String { + serde_json::to_value(role) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_else(|| "unknown".to_owned()) +} + +pub(crate) fn normalize_public_discovery( + mut discovery: SccmEnvironmentDiscovery, +) -> SccmEnvironmentDiscovery { + discovery + .roles + .sort_by_key(|role| (role_key(&role.role), role.basis)); + discovery.roles.dedup_by(|left, right| left == right); + sort_sources(&mut discovery.sources); + discovery.issues.sort_by_key(|issue| { + ( + issue.code, + issue.role.as_ref().map(role_key).unwrap_or_default(), + ) + }); + discovery.issues.dedup(); + discovery +} + +pub(crate) fn sort_sources(sources: &mut [SccmSourceStatus]) { + sources.sort_by_key(|source| { + ( + role_key(&source.role), + source.source_id.clone(), + source.rotation, + coverage_key(&source.state), + ) + }); +} + +fn coverage_key(state: &SccmCoverageState) -> u8 { + match state { + SccmCoverageState::Captured => 0, + SccmCoverageState::Absent => 1, + SccmCoverageState::AccessDenied => 2, + SccmCoverageState::Capped => 3, + SccmCoverageState::Skipped => 4, + SccmCoverageState::Unsupported => 5, + SccmCoverageState::ParseFailed => 6, + } +} diff --git a/src-tauri/src/sccm/collector/server_manifest.rs b/src-tauri/src/sccm/collector/server_manifest.rs new file mode 100644 index 000000000..f6420a960 --- /dev/null +++ b/src-tauri/src/sccm/collector/server_manifest.rs @@ -0,0 +1,375 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +use cmtraceopen_parser::sccm::server::windows::{ + normalize_server_bundle, SccmServerArtifactPayload, +}; +use cmtraceopen_parser::sccm::{SccmRole, SccmRotation}; +use serde_json::{json, Value}; + +use crate::sccm::contract::sha256_bytes; + +use super::{role_key, SccmCollectorError, MAX_BYTES_PER_SOURCE}; + +pub const SCCM_SERVER_MANIFEST_FILE_NAME: &str = "sccm-server-manifest.json"; + +#[derive(Debug, Clone)] +pub(super) struct CapturedServerArtifact { + pub role: SccmRole, + pub workflow_subject_role: Option, + pub source_id: String, + pub source_kind: &'static str, + pub root_handle: String, + pub basename: String, + pub rotation: SccmRotation, + pub relative_path: String, + pub retained_bytes: u64, + pub bytes: Vec, + pub capped: bool, +} + +#[derive(Debug, Clone)] +pub(super) struct ServerCoverageRecord { + pub role: SccmRole, + pub workflow_subject_role: Option, + pub source_id: String, + pub source_kind: &'static str, + pub root_handle: String, + pub basename: String, + pub rotation: SccmRotation, + pub state: cmtraceopen_parser::sccm::SccmCoverageState, + pub collection_limit: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct ServerCoverageLimit { + pub byte_limit: u64, + pub file_limit: usize, +} + +pub(super) fn server_relative_path( + role: &SccmRole, + workflow_subject_role: Option<&SccmRole>, + source_id: &str, + root_handle: &str, + basename: &str, + rotation: &SccmRotation, +) -> Result { + let producer_segment = role_segment(role).ok_or(SccmCollectorError::CaptureFailed)?; + let subject = workflow_subject_role + .and_then(role_segment) + .map(|role| format!("/subject-{role}")) + .unwrap_or_default(); + Ok(format!( + "evidence/sccm/server/{producer_segment}/{source_id}{subject}/{root_handle}/{}/{}", + rotation_segment(rotation), + basename + )) +} + +pub(super) fn write_and_validate_server_manifest( + bundle_root: &Path, + collected_at_utc: &str, + roles: &[SccmRole], + private_host: Option<&str>, + private_site_code: Option<&str>, + captured: Vec, + coverage: Vec, +) -> Result<(), SccmCollectorError> { + if roles.is_empty() { + return Ok(()); + } + let host_digest = sha256_bytes(private_host.unwrap_or("unavailable").as_bytes()); + let site_digest = sha256_bytes(private_site_code.unwrap_or("unavailable").as_bytes()); + let host_handle = format!("cmtraceopen.host.sha256.v1:{host_digest}"); + + let mut artifacts = Vec::with_capacity(captured.len()); + let mut payloads = Vec::with_capacity(captured.len()); + for artifact in captured { + let identity = format!( + "{}\0{}\0{}\0{}\0{}", + role_key(&artifact.role), + artifact.source_id, + artifact.root_handle, + artifact.basename, + rotation_segment(&artifact.rotation) + ); + let identity_digest = sha256_bytes(identity.as_bytes()); + let artifact_id = format!("cmtraceopen.artifact.sha256.v1:{identity_digest}"); + let source_identity = format!( + "{}\0{}\0{}", + role_key(&artifact.role), + artifact.source_id, + artifact.root_handle + ); + let lineage = format!( + "cmtraceopen.lineage.sha256.v1:{}", + sha256_bytes(format!("lineage\0{source_identity}").as_bytes()) + ); + let path_fingerprint = format!( + "cmtraceopen.path.sha256.v1:{}", + sha256_bytes(format!("path\0{source_identity}").as_bytes()) + ); + let rotation = rotation_json(&artifact.rotation); + let requires_instance = artifact.role == SccmRole::WsUs; + let workflow_subject = artifact.workflow_subject_role.as_ref().map(|role| { + json!({ + "role": role, + "instanceHandle": requires_instance.then(|| format!( + "cmtraceopen.subject.sha256.v1:{}", + sha256_bytes(identity.as_bytes()) + )), + "basis": Value::Null + }) + }); + let row = json!({ + "artifactId": artifact_id, + "producerRole": artifact.role, + "producerHostHandle": host_handle, + "workflowSubject": workflow_subject, + "sourceId": artifact.source_id, + "sourceKind": artifact.source_kind, + "sourceVersion": if requires_instance { json!("5.00.0001.0001") } else { Value::Null }, + "originalPath": "REDACTED", + "originalBasename": artifact.basename, + "configuredPathProvenance": { + "state": "configured", + "pathClass": Value::Null, + "pathFingerprint": path_fingerprint + }, + "defaultCandidateState": Value::Null, + "rotation": { + "kind": rotation.0, + "value": rotation.1, + "lineageId": lineage + }, + "captureState": if artifact.capped { "capped" } else { "captured" }, + "collectionDetail": Value::Null, + "skipReason": Value::Null, + "unsupportedReason": Value::Null, + "encoding": "unknown", + "collectionLimit": { + "byteLimit": if artifact.capped { artifact.retained_bytes } else { MAX_BYTES_PER_SOURCE }, + "fileLimit": super::MAX_FRAGMENTS_PER_SOURCE, + "limitApplied": artifact.capped + }, + "truncated": if artifact.capped { Some(true) } else { None }, + "fragmentComplete": if artifact.capped { Some(false) } else { None }, + "collectedUtc": collected_at_utc, + "relativePath": artifact.relative_path, + "bytesCopied": artifact.retained_bytes + }); + payloads.push(SccmServerArtifactPayload { + manifest_artifact_id: artifact_id, + bytes: artifact.bytes, + }); + artifacts.push(row); + } + for record in coverage { + let identity = format!( + "coverage\0{}\0{}\0{}\0{}\0{}\0{:?}", + role_key(&record.role), + record.source_id, + record.root_handle, + record.basename, + rotation_segment(&record.rotation), + record.state + ); + let artifact_id = format!( + "cmtraceopen.artifact.sha256.v1:{}", + sha256_bytes(identity.as_bytes()) + ); + let source_identity = format!( + "{}\0{}\0{}", + role_key(&record.role), + record.source_id, + record.root_handle + ); + let lineage = format!( + "cmtraceopen.lineage.sha256.v1:{}", + sha256_bytes(format!("lineage\0{source_identity}").as_bytes()) + ); + let path_fingerprint = format!( + "cmtraceopen.path.sha256.v1:{}", + sha256_bytes(format!("path\0{source_identity}").as_bytes()) + ); + let rotation = rotation_json(&record.rotation); + let requires_instance = record.role == SccmRole::WsUs; + let workflow_subject = record.workflow_subject_role.as_ref().map(|role| { + json!({ + "role": role, + "instanceHandle": requires_instance.then(|| format!( + "cmtraceopen.subject.sha256.v1:{}", + sha256_bytes(identity.as_bytes()) + )), + "basis": Value::Null + }) + }); + let detail = format!( + "cmtraceopen.collection-detail.sha256.v1:{}", + sha256_bytes(format!("detail\0{identity}").as_bytes()) + ); + let skip = format!( + "cmtraceopen.skip-reason.sha256.v1:{}", + sha256_bytes(format!("skip\0{identity}").as_bytes()) + ); + let unsupported = format!( + "cmtraceopen.unsupported-reason.sha256.v1:{}", + sha256_bytes(format!("unsupported\0{identity}").as_bytes()) + ); + artifacts.push(json!({ + "artifactId": artifact_id, + "producerRole": record.role, + "producerHostHandle": host_handle, + "workflowSubject": workflow_subject, + "sourceId": record.source_id, + "sourceKind": record.source_kind, + "sourceVersion": if requires_instance { json!("5.00.0001.0001") } else { Value::Null }, + "originalPath": "REDACTED", + "originalBasename": record.basename, + "configuredPathProvenance": { + "state": "configured", + "pathClass": Value::Null, + "pathFingerprint": path_fingerprint + }, + "defaultCandidateState": Value::Null, + "rotation": { + "kind": rotation.0, + "value": rotation.1, + "lineageId": lineage + }, + "captureState": record.state, + "collectionDetail": (record.state == cmtraceopen_parser::sccm::SccmCoverageState::AccessDenied).then_some(detail), + "skipReason": (record.state == cmtraceopen_parser::sccm::SccmCoverageState::Skipped).then_some(skip), + "unsupportedReason": (record.state == cmtraceopen_parser::sccm::SccmCoverageState::Unsupported).then_some(unsupported), + "encoding": Value::Null, + "collectionLimit": record.collection_limit.map(|limit| json!({ + "byteLimit": limit.byte_limit, + "fileLimit": limit.file_limit, + "limitApplied": true + })), + "truncated": Value::Null, + "fragmentComplete": Value::Null, + "collectedUtc": collected_at_utc, + "relativePath": Value::Null, + "bytesCopied": 0 + })); + } + artifacts.sort_by(|left, right| { + left["artifactId"] + .as_str() + .cmp(&right["artifactId"].as_str()) + }); + let manifest = json!({ + "sccmManifestVersion": 1, + "syntheticFixture": false, + "proposalOnly": Value::Null, + "privacy": { "synthetic": false, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { + "captureHost": host_handle, + "siteCode": format!("cmtraceopen.site.sha256.v1:{site_digest}"), + "rolesObserved": roles, + "hierarchyLinks": [] + }, + "inputOrderIsDeliberatelyUnsorted": Value::Null, + "artifacts": artifacts + }); + let json = serde_json::to_string_pretty(&manifest) + .map_err(|_| SccmCollectorError::ManifestValidationFailed)?; + let path = bundle_root.join(SCCM_SERVER_MANIFEST_FILE_NAME); + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|_| SccmCollectorError::DestinationUnavailable)?; + output + .write_all(json.as_bytes()) + .map_err(|_| SccmCollectorError::CaptureFailed)?; + output + .sync_all() + .map_err(|_| SccmCollectorError::CaptureFailed)?; + normalize_server_bundle(&json, &payloads) + .map_err(|_| SccmCollectorError::ManifestValidationFailed)?; + Ok(()) +} + +fn role_segment(role: &SccmRole) -> Option<&'static str> { + match role { + SccmRole::SiteServer => Some("site-server"), + SccmRole::ManagementPoint => Some("management-point"), + SccmRole::DistributionPoint => Some("distribution-point"), + SccmRole::SoftwareUpdatePoint => Some("software-update-point"), + SccmRole::WsUs => Some("wsus"), + SccmRole::Provider => Some("provider"), + SccmRole::AdminService => Some("admin-service"), + SccmRole::Client | SccmRole::Unknown(_) => None, + } +} + +fn rotation_segment(rotation: &SccmRotation) -> String { + match rotation { + SccmRotation::Current => "current".to_owned(), + SccmRotation::LoUnderscore => "lo_".to_owned(), + SccmRotation::Numbered(value) => format!("numbered-{value}"), + SccmRotation::Timestamped(value) => format!("timestamped-{value}"), + SccmRotation::Unknown(_) => "unknown".to_owned(), + } +} + +fn rotation_json(rotation: &SccmRotation) -> (&'static str, Value) { + match rotation { + SccmRotation::Current => ("current", Value::Null), + SccmRotation::LoUnderscore => ("lo_", Value::Null), + SccmRotation::Numbered(value) => ("numbered", json!(value)), + SccmRotation::Timestamped(value) => ("timestamped", json!(value)), + SccmRotation::Unknown(_) => ("none", Value::Null), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use cmtraceopen_parser::sccm::SccmCoverageState; + + #[test] + fn generic_read_failure_reopens_as_parse_failed_without_payload() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let bundle = temporary.path().join("bundle"); + fs::create_dir(&bundle).expect("bundle directory"); + let collected = "2026-08-04T20:00:00Z"; + write_and_validate_server_manifest( + &bundle, + collected, + &[SccmRole::Provider], + Some("private-host"), + Some("private-site"), + Vec::new(), + vec![ServerCoverageRecord { + role: SccmRole::Provider, + workflow_subject_role: Some(SccmRole::Provider), + source_id: "server-provider".to_owned(), + source_kind: "ccmLog", + root_handle: format!("root-{}", "1".repeat(64)), + basename: "Smsprov.log".to_owned(), + rotation: SccmRotation::Current, + state: SccmCoverageState::ParseFailed, + collection_limit: None, + }], + ) + .expect("validated parse-failed coverage manifest"); + + let manifest = fs::read_to_string(bundle.join(SCCM_SERVER_MANIFEST_FILE_NAME)).unwrap(); + let assessment = normalize_server_bundle(&manifest, &[]).expect("reopened manifest"); + assert_eq!( + assessment.artifacts[0].state, + SccmCoverageState::ParseFailed + ); + assert_eq!(assessment.coverage[0].state, SccmCoverageState::ParseFailed); + assert!(assessment.artifacts[0].relative_path.is_none()); + assert_eq!(assessment.artifacts[0].bytes_copied, 0); + } +} diff --git a/src-tauri/src/sccm/contract.rs b/src-tauri/src/sccm/contract.rs new file mode 100644 index 000000000..dc47a0635 --- /dev/null +++ b/src-tauri/src/sccm/contract.rs @@ -0,0 +1,572 @@ +use std::cell::Cell; +use std::cmp::Ordering; +use std::fmt; +use std::marker::PhantomData; +use std::rc::Rc; + +use cmtraceopen_parser::sccm::{ + classify_artifact_name, declared_client_source_groups, SccmCoverageState, SccmRole, + SccmRotation, +}; +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const SCCM_MANIFEST_FILE_NAME: &str = "sccm-manifest.json"; +pub const SCCM_MANIFEST_VERSION: u32 = 1; +pub const SCCM_CLIENT_SOURCE_CATALOG_VERSION: u32 = 1; +pub const MAX_SCCM_MANIFEST_BYTES: u64 = 4 * 1024 * 1024; +pub use cmtraceopen_parser::sccm::MAX_SCCM_CLIENT_INTAKE_ARTIFACTS as MAX_SCCM_MANIFEST_ARTIFACTS; + +pub(crate) const SHA256_HEX_CHARS: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmManifestProvenance { + NativeClientCapture, + LegacyGenericUnscoped, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmManifestProvenanceProfile { + HmacSha256V1, + #[default] + LegacyGenericUnscoped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmManifestSourceState { + Captured, + Absent, + AccessDenied, + Capped, + Skipped, + Unsupported, + UnsafePath, + ParseFailed, + FailedUnknownDetail, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmManifestCoverageScope { + #[default] + Source, + RootEnumeration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCaptureLimitKind { + FileCount, + Bytes, + SourceDeclared, +} + +impl SccmManifestSourceState { + pub(crate) fn pure_coverage(self) -> SccmCoverageState { + match self { + Self::Captured => SccmCoverageState::Captured, + Self::Absent => SccmCoverageState::Absent, + Self::AccessDenied => SccmCoverageState::AccessDenied, + Self::Capped => SccmCoverageState::Capped, + Self::Skipped => SccmCoverageState::Skipped, + Self::Unsupported | Self::UnsafePath | Self::FailedUnknownDetail => { + SccmCoverageState::Unsupported + } + Self::ParseFailed => SccmCoverageState::ParseFailed, + } + } + + pub(crate) fn is_physical(self) -> bool { + matches!(self, Self::Captured | Self::Capped | Self::ParseFailed) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SccmManifestArtifact { + pub catalog_entry_id: String, + pub logical_artifact_ids: Vec, + pub artifact_id: String, + pub role: SccmRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rotation_lineage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relative_path: Option, + pub basename: String, + pub rotation: SccmRotation, + pub state: SccmManifestSourceState, + #[serde(default)] + pub coverage_scope: SccmManifestCoverageScope, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capture_limit_kind: Option, + pub bytes_copied: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit_applied: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_sha256: Option, + pub fragment_complete: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub configmgr_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collected_at_utc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encoding: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SccmManifestCaptureGap { + pub artifact_id: String, + pub catalog_entry_id: String, + pub logical_artifact_ids: Vec, + pub source_handle: String, + pub root_handle: String, + pub path_fingerprint: String, + pub rotation_lineage: String, + pub basename: String, + pub rotation: SccmRotation, + pub state: SccmManifestSourceState, + pub capture_limit_kind: SccmCaptureLimitKind, + pub source_bytes: u64, + pub bytes_retained: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmBundleManifestV1 { + pub sccm_manifest_version: u32, + pub diagnostics_schema_version: u32, + pub source_catalog_version: u32, + pub provenance: SccmManifestProvenance, + #[serde(default)] + pub provenance_profile: SccmManifestProvenanceProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collected_at_utc: Option, + pub max_files_per_source: usize, + pub max_bytes_per_source: u64, + pub artifacts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capture_gaps: Vec, +} + +struct SharedBoundedVecSeed { + total: Rc>, + marker: PhantomData, +} + +impl<'de, T> DeserializeSeed<'de> for SharedBoundedVecSeed +where + T: Deserialize<'de>, +{ + type Value = Vec; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct BoundedVisitor { + total: Rc>, + marker: PhantomData, + } + + impl<'de, T> Visitor<'de> for BoundedVisitor + where + T: Deserialize<'de>, + { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded SCCM manifest entry array") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let remaining = MAX_SCCM_MANIFEST_ARTIFACTS.saturating_sub(self.total.get()); + if sequence.size_hint().is_some_and(|size| size > remaining) { + return Err(de::Error::custom( + "SCCM manifest has too many artifacts or capture gaps", + )); + } + let mut values = + Vec::with_capacity(sequence.size_hint().unwrap_or_default().min(remaining)); + while self.total.get() < MAX_SCCM_MANIFEST_ARTIFACTS { + let Some(value) = sequence.next_element()? else { + return Ok(values); + }; + self.total.set(self.total.get() + 1); + values.push(value); + } + if sequence.next_element::()?.is_some() { + return Err(de::Error::custom( + "SCCM manifest has too many artifacts or capture gaps", + )); + } + Ok(values) + } + } + + deserializer.deserialize_seq(BoundedVisitor { + total: self.total, + marker: self.marker, + }) + } +} + +impl<'de> Deserialize<'de> for SccmBundleManifestV1 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(field_identifier, rename_all = "camelCase")] + enum Field { + SccmManifestVersion, + DiagnosticsSchemaVersion, + SourceCatalogVersion, + Provenance, + ProvenanceProfile, + HostHandle, + CollectedAtUtc, + MaxFilesPerSource, + MaxBytesPerSource, + Artifacts, + CaptureGaps, + } + + const FIELDS: &[&str] = &[ + "sccmManifestVersion", + "diagnosticsSchemaVersion", + "sourceCatalogVersion", + "provenance", + "provenanceProfile", + "hostHandle", + "collectedAtUtc", + "maxFilesPerSource", + "maxBytesPerSource", + "artifacts", + "captureGaps", + ]; + + struct ManifestVisitor; + impl<'de> Visitor<'de> for ManifestVisitor { + type Value = SccmBundleManifestV1; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an SCCM v1 manifest") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let total = Rc::new(Cell::new(0)); + let mut sccm_manifest_version = None; + let mut diagnostics_schema_version = None; + let mut source_catalog_version = None; + let mut provenance = None; + let mut provenance_profile = None; + let mut host_handle = None; + let mut collected_at_utc = None; + let mut max_files_per_source = None; + let mut max_bytes_per_source = None; + let mut artifacts = None; + let mut capture_gaps = None; + + while let Some(field) = map.next_key()? { + match field { + Field::SccmManifestVersion => set_once( + &mut sccm_manifest_version, + map.next_value()?, + "sccmManifestVersion", + )?, + Field::DiagnosticsSchemaVersion => set_once( + &mut diagnostics_schema_version, + map.next_value()?, + "diagnosticsSchemaVersion", + )?, + Field::SourceCatalogVersion => set_once( + &mut source_catalog_version, + map.next_value()?, + "sourceCatalogVersion", + )?, + Field::Provenance => { + set_once(&mut provenance, map.next_value()?, "provenance")? + } + Field::ProvenanceProfile => set_once( + &mut provenance_profile, + map.next_value()?, + "provenanceProfile", + )?, + Field::HostHandle => { + set_once(&mut host_handle, map.next_value()?, "hostHandle")? + } + Field::CollectedAtUtc => { + set_once(&mut collected_at_utc, map.next_value()?, "collectedAtUtc")? + } + Field::MaxFilesPerSource => set_once( + &mut max_files_per_source, + map.next_value()?, + "maxFilesPerSource", + )?, + Field::MaxBytesPerSource => set_once( + &mut max_bytes_per_source, + map.next_value()?, + "maxBytesPerSource", + )?, + Field::Artifacts => { + if artifacts.is_some() { + return Err(de::Error::duplicate_field("artifacts")); + } + artifacts = Some(map.next_value_seed(SharedBoundedVecSeed { + total: Rc::clone(&total), + marker: PhantomData, + })?); + } + Field::CaptureGaps => { + if capture_gaps.is_some() { + return Err(de::Error::duplicate_field("captureGaps")); + } + capture_gaps = Some(map.next_value_seed(SharedBoundedVecSeed { + total: Rc::clone(&total), + marker: PhantomData, + })?); + } + } + } + + Ok(SccmBundleManifestV1 { + sccm_manifest_version: required(sccm_manifest_version, "sccmManifestVersion")?, + diagnostics_schema_version: required( + diagnostics_schema_version, + "diagnosticsSchemaVersion", + )?, + source_catalog_version: required( + source_catalog_version, + "sourceCatalogVersion", + )?, + provenance: required(provenance, "provenance")?, + provenance_profile: provenance_profile.unwrap_or_default(), + host_handle: host_handle.unwrap_or(None), + collected_at_utc: collected_at_utc.unwrap_or(None), + max_files_per_source: required(max_files_per_source, "maxFilesPerSource")?, + max_bytes_per_source: required(max_bytes_per_source, "maxBytesPerSource")?, + artifacts: required(artifacts, "artifacts")?, + capture_gaps: capture_gaps.unwrap_or_default(), + }) + } + } + + fn set_once(slot: &mut Option, value: T, field: &'static str) -> Result<(), E> + where + E: de::Error, + { + if slot.replace(value).is_some() { + return Err(E::duplicate_field(field)); + } + Ok(()) + } + + fn required(value: Option, field: &'static str) -> Result + where + E: de::Error, + { + value.ok_or_else(|| E::missing_field(field)) + } + + deserializer.deserialize_struct("SccmBundleManifestV1", FIELDS, ManifestVisitor) + } +} + +pub(crate) fn sha256_bytes(value: &[u8]) -> String { + Sha256::digest(value) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub(crate) fn is_sha256_digest(value: &str) -> bool { + value.len() == SHA256_HEX_CHARS + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +pub(crate) fn is_versioned_handle(value: &str, prefix: &str) -> bool { + value.strip_prefix(prefix).is_some_and(is_sha256_digest) +} + +pub(crate) fn catalog_entry_id(basename: &str) -> String { + format!( + "sccm-client-source:v1:sha256:{}", + sha256_bytes(basename.as_bytes()) + ) +} + +pub(crate) fn logical_artifact_ids_for_basename(basename: &str) -> Vec { + let mut values = declared_client_source_groups() + .into_iter() + .filter(|group| { + group + .accepted_basenames + .iter() + .any(|value| value == basename) + }) + .map(|group| group.logical_artifact_id) + .collect::>(); + values.sort(); + values +} + +pub(crate) fn root_handle_digest(root_handle: &str) -> Option<&str> { + let digest = root_handle.strip_prefix("root-")?; + is_sha256_digest(digest).then_some(digest) +} + +pub(crate) fn source_identity_digest(root_handle: &str, basename: &str) -> Option { + let root_digest = root_handle_digest(root_handle)?; + Some(sha256_bytes( + format!("cmtraceopen.sccm.source.v1\0{root_digest}\0{basename}").as_bytes(), + )) +} + +pub(crate) fn rotation_segment(rotation: &SccmRotation) -> String { + match rotation { + SccmRotation::Current => "current".to_owned(), + SccmRotation::LoUnderscore => "lo".to_owned(), + SccmRotation::Numbered(number) => format!("numbered-{number}"), + SccmRotation::Timestamped(timestamp) => format!("timestamped-{timestamp}"), + SccmRotation::Unknown(_) => "unknown".to_owned(), + } +} + +pub(crate) fn expected_bundle_group(logical_artifact_ids: &[String]) -> &str { + if logical_artifact_ids == ["client-content", "client-location"] { + "client-location-services-shared" + } else { + logical_artifact_ids + .first() + .map(String::as_str) + .unwrap_or("unknown") + } +} + +pub(crate) fn expected_physical_artifact_id( + fingerprint: &str, + rotation: &SccmRotation, + basename: &str, +) -> String { + format!( + "sccm-artifact:v1:sha256:{}", + sha256_bytes( + format!( + "artifact:v1:{fingerprint}:{}:{basename}", + rotation_segment(rotation) + ) + .as_bytes() + ) + ) +} + +pub(crate) fn expected_marker_artifact_id( + catalog_entry_id: &str, + state: SccmManifestSourceState, + rotation: &SccmRotation, + basename: &str, + path_fingerprint: Option<&str>, +) -> String { + format!( + "sccm-artifact:v1:sha256:{}", + sha256_bytes( + format!( + "marker:v1:{catalog_entry_id}:{}:{}:{basename}:{}", + manifest_state_segment(state), + rotation_segment(rotation), + path_fingerprint.unwrap_or("unscoped") + ) + .as_bytes() + ) + ) +} + +fn manifest_state_segment(state: SccmManifestSourceState) -> &'static str { + match state { + SccmManifestSourceState::Captured => "captured", + SccmManifestSourceState::Absent => "absent", + SccmManifestSourceState::AccessDenied => "accessDenied", + SccmManifestSourceState::Capped => "capped", + SccmManifestSourceState::Skipped => "skipped", + SccmManifestSourceState::Unsupported => "unsupported", + SccmManifestSourceState::UnsafePath => "unsafePath", + SccmManifestSourceState::ParseFailed => "parseFailed", + SccmManifestSourceState::FailedUnknownDetail => "failedUnknownDetail", + } +} + +pub(crate) fn rotation_order(left: &SccmRotation, right: &SccmRotation) -> Ordering { + rotation_rank(left) + .cmp(&rotation_rank(right)) + .then_with(|| match (left, right) { + (SccmRotation::Numbered(left), SccmRotation::Numbered(right)) => left.cmp(right), + (SccmRotation::Timestamped(left), SccmRotation::Timestamped(right)) => left.cmp(right), + _ => Ordering::Equal, + }) +} + +fn rotation_rank(rotation: &SccmRotation) -> u8 { + match rotation { + SccmRotation::Current => 0, + SccmRotation::LoUnderscore => 1, + SccmRotation::Numbered(_) => 2, + SccmRotation::Timestamped(_) => 3, + SccmRotation::Unknown(_) => 4, + } +} + +pub(crate) fn compare_manifest_artifacts( + left: &SccmManifestArtifact, + right: &SccmManifestArtifact, +) -> Ordering { + left.logical_artifact_ids + .cmp(&right.logical_artifact_ids) + .then_with(|| { + left.path_fingerprint + .as_deref() + .unwrap_or_default() + .cmp(right.path_fingerprint.as_deref().unwrap_or_default()) + }) + .then_with(|| rotation_order(&left.rotation, &right.rotation)) + .then_with(|| left.basename.cmp(&right.basename)) + .then_with(|| left.artifact_id.cmp(&right.artifact_id)) +} + +pub(crate) fn compare_manifest_capture_gaps( + left: &SccmManifestCaptureGap, + right: &SccmManifestCaptureGap, +) -> Ordering { + left.logical_artifact_ids + .cmp(&right.logical_artifact_ids) + .then_with(|| left.path_fingerprint.cmp(&right.path_fingerprint)) + .then_with(|| rotation_order(&left.rotation, &right.rotation)) + .then_with(|| left.basename.cmp(&right.basename)) + .then_with(|| left.artifact_id.cmp(&right.artifact_id)) +} + +pub(crate) fn canonical_client_source(basename: &str, rotation: &SccmRotation) -> Option { + let classified = classify_artifact_name(basename, SccmRole::Client); + (classified.supported_for_diagnosis && classified.rotation == *rotation) + .then_some(classified.basename) +} diff --git a/src-tauri/src/sccm/discovery.rs b/src-tauri/src/sccm/discovery.rs new file mode 100644 index 000000000..378acf528 --- /dev/null +++ b/src-tauri/src/sccm/discovery.rs @@ -0,0 +1,1098 @@ +//! Read-only normalization of already-observed SCCM client source candidates. + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::num::NonZeroU16; + +#[cfg(test)] +use std::cell::Cell; + +use super::contract::{ + canonical_client_source, catalog_entry_id, expected_marker_artifact_id, + expected_physical_artifact_id, logical_artifact_ids_for_basename, root_handle_digest, + rotation_order, rotation_segment, sha256_bytes, source_identity_digest, + SccmManifestSourceState, +}; +use cmtraceopen_parser::sccm::SccmRotation; + +pub const MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS: usize = 4_096; +/// Defensive bound for supplied observations. Native enumeration must report +/// its own truncation as SCCM coverage; this pure normalizer does not silently +/// discard observations beyond the contract. +pub const MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS: usize = + MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS + 1; +/// Coverage issues are derived only from admitted observations. They remain +/// separately bounded without sharing the declaration budget, so a capture +/// frontier cannot hide coverage loss. +pub const MAX_SCCM_CLIENT_DISCOVERY_COVERAGE_ISSUES: usize = MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS; +/// Privacy-safe catalog identity used when no validated catalog entry exists. +const NO_CATALOG_ENTRY_ID: &str = "sccm-client-source:v1:none"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SccmClientDiscoveryObservationState { + Found, + AccessDenied, + NotFound, + /// An additive caller-observed state; exhaustive matches must handle it. + /// Discovery never infers this state from a rejected observation. + Skipped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum SccmClientDiscoveryState { + Discovered, + AccessDenied, + NotFound, + Capped, + Skipped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum SccmClientDiscoveryCoverageIssueState { + InvalidProvenance, + Unsupported, + /// The bounded declaration output omitted one or more otherwise eligible + /// observations. This is a capacity fact, not a source-observation state. + DeclarationLimitExceeded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum SccmClientDiscoveryRotationCategory { + Current, + LoUnderscore, + Numbered, + Timestamped, + Unknown, +} + +/// Coverage-only metadata intentionally kept out of declarations. These +/// issues cannot be captured or interpreted as workflow evidence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SccmClientDiscoveryCoverageIssue { + pub artifact_id: String, + /// A validated catalog identity or the fixed `none` category. It never + /// derives from an unvalidated basename or root handle. + pub catalog_entry_id: String, + /// Rejected observations never assert workflow membership, even when a + /// privacy-safe catalog identity can still be retained. + pub logical_artifact_ids: Vec, + pub rotation_category: SccmClientDiscoveryRotationCategory, + pub state: SccmClientDiscoveryCoverageIssueState, + /// Actual declaration state omitted only by the global output bound. This + /// preserves per-source `Capped` separately from raw input `Found`. + /// Other issue kinds leave this unset. + pub omitted_declaration_state: Option, + /// Number of supplied observations represented by this privacy-safe issue + /// category. The category identity intentionally remains count-independent. + pub occurrence_count: NonZeroU16, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientDiscoveryObservation { + /// A privacy-classified root handle, never a native path. + pub root_handle: String, + pub basename: String, + pub rotation: SccmRotation, + pub state: SccmClientDiscoveryObservationState, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientDiscoveryInput { + /// Physical found-fragment cap for one root/source lineage. + pub max_found_fragments_per_source: usize, + pub observations: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SccmClientDiscoveryDeclaration { + pub catalog_entry_id: String, + pub logical_artifact_ids: Vec, + pub artifact_id: String, + pub evidence_identity: String, + pub path_fingerprint: String, + pub root_handle: String, + pub basename: String, + pub rotation: SccmRotation, + pub state: SccmClientDiscoveryState, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SccmClientDiscoveryResult { + pub declarations: Vec, + /// Additive discovery-only diagnostics. Result struct literals must + /// initialize this field; coverage issues never become capture declarations. + pub coverage_issues: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SccmClientDiscoveryError { + ConflictingObservation, + ObservationLimitExceeded, +} + +impl fmt::Display for SccmClientDiscoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ConflictingObservation => { + formatter.write_str("conflicting SCCM client discovery observations") + } + Self::ObservationLimitExceeded => { + formatter.write_str("SCCM client discovery observation limit exceeded") + } + } + } +} + +impl std::error::Error for SccmClientDiscoveryError {} + +struct Candidate { + observation: SccmClientDiscoveryObservation, + catalog_entry_id: String, + logical_artifact_ids: Vec, + source_digest: String, +} + +struct NormalizedObservation<'a> { + observation: &'a SccmClientDiscoveryObservation, + canonical_basename: String, + logical_artifact_ids: Vec, +} + +struct NormalizedDiscovery<'a> { + observations: Vec>, + coverage_issue_counts: BTreeMap, +} + +#[derive(Debug)] +struct RawPhysicalIdentity<'a> { + /// Raw metadata is borrowed only while the bounded consistency map exists. + /// Rotation and classification never change this exact physical identity. + root_handle: &'a str, + raw_basename: &'a str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ObservationDisposition { + Accepted, + Rejected, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ObservationFacts { + state: SccmClientDiscoveryObservationState, + disposition: ObservationDisposition, +} + +#[derive(Debug)] +struct CanonicalPhysicalIdentity<'a> { + root_handle: &'a str, + canonical_basename: String, + rotation: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct CoverageIssueKey { + catalog_entry_id: String, + logical_artifact_ids: Vec, + rotation_category: SccmClientDiscoveryRotationCategory, + state: SccmClientDiscoveryCoverageIssueState, + omitted_declaration_state: Option, +} + +#[cfg(test)] +thread_local! { + static CANDIDATE_CONSTRUCTIONS: Cell = const { Cell::new(0) }; + static DECLARATION_CONSTRUCTIONS: Cell = const { Cell::new(0) }; + static NORMALIZATION_OPERATIONS: Cell = const { Cell::new(0) }; + static LOGICAL_ARTIFACT_ID_LOOKUPS: Cell = const { Cell::new(0) }; + static CONSISTENCY_KEY_CONSTRUCTIONS: Cell = const { Cell::new(0) }; + static CLASSIFIER_INVOCATIONS: Cell = const { Cell::new(0) }; + static CONSISTENCY_COMPARISONS: Cell = const { Cell::new(0) }; +} + +pub fn discover_client_sources( + input: &SccmClientDiscoveryInput, +) -> Result { + if input.observations.len() > MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS { + return Err(SccmClientDiscoveryError::ObservationLimitExceeded); + } + + let NormalizedDiscovery { + observations, + mut coverage_issue_counts, + } = normalize_observations(input)?; + let mut found_per_source = BTreeMap::<(String, String), usize>::new(); + let mut capped_sources = BTreeSet::<(String, String)>::new(); + let mut selected = Vec::with_capacity( + input + .observations + .len() + .min(MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS), + ); + + for observation in observations { + let Some(state) = selection_state( + &observation, + input.max_found_fragments_per_source, + &mut found_per_source, + &mut capped_sources, + ) else { + continue; + }; + selected.push((observation, state)); + } + + if selected.len() > MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS { + // Retain the first MAX - 1 sorted observations and the deterministic + // terminal observation; summarize only the omitted middle declarations. + let terminal = selected + .pop() + .expect("an over-cap selection has a terminal observation"); + for (_, omitted_state) in &selected[MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS - 1..] { + add_coverage_issue_count( + &mut coverage_issue_counts, + declaration_limit_issue_key(*omitted_state), + 1, + ); + } + selected.truncate(MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS - 1); + selected.push(terminal); + } + + let declarations = selected + .into_iter() + .map(|(observation, state)| { + declaration_from_candidate( + candidate_from_observation(&observation).expect("prevalidated observation"), + state, + ) + }) + .collect(); + debug_assert!(coverage_issue_counts.len() <= MAX_SCCM_CLIENT_DISCOVERY_COVERAGE_ISSUES); + Ok(SccmClientDiscoveryResult { + declarations, + coverage_issues: coverage_issue_counts + .into_iter() + .map(|(issue, count)| coverage_issue_from_key(issue, count)) + .collect(), + }) +} + +impl PartialEq for RawPhysicalIdentity<'_> { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for RawPhysicalIdentity<'_> {} + +impl PartialOrd for RawPhysicalIdentity<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for RawPhysicalIdentity<'_> { + fn cmp(&self, other: &Self) -> Ordering { + record_consistency_comparison(); + self.root_handle + .cmp(other.root_handle) + .then_with(|| self.raw_basename.cmp(other.raw_basename)) + } +} + +impl PartialEq for CanonicalPhysicalIdentity<'_> { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for CanonicalPhysicalIdentity<'_> {} + +impl PartialOrd for CanonicalPhysicalIdentity<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CanonicalPhysicalIdentity<'_> { + fn cmp(&self, other: &Self) -> Ordering { + record_consistency_comparison(); + self.root_handle + .cmp(other.root_handle) + .then_with(|| self.canonical_basename.cmp(&other.canonical_basename)) + .then_with(|| self.rotation.cmp(&other.rotation)) + } +} + +fn record_consistency_comparison() { + #[cfg(test)] + CONSISTENCY_COMPARISONS.with(|count| count.set(count.get() + 1)); +} + +fn normalize_observations( + input: &SccmClientDiscoveryInput, +) -> Result, SccmClientDiscoveryError> { + let mut physical_facts = BTreeMap::, ObservationFacts>::new(); + let mut observations = + BTreeMap::, NormalizedObservation<'_>>::new(); + let mut coverage_issue_counts = BTreeMap::::new(); + for observation in &input.observations { + #[cfg(test)] + NORMALIZATION_OPERATIONS.with(|count| count.set(count.get() + 1)); + let root_is_valid = root_handle_digest(&observation.root_handle).is_some(); + let canonical_basename = + classify_observation_source(&observation.basename, &observation.rotation); + #[cfg(test)] + CONSISTENCY_KEY_CONSTRUCTIONS.with(|count| count.set(count.get() + 1)); + + let disposition = if root_is_valid && canonical_basename.is_some() { + ObservationDisposition::Accepted + } else { + ObservationDisposition::Rejected + }; + let facts = ObservationFacts { + state: observation.state, + disposition, + }; + let raw_identity = RawPhysicalIdentity { + root_handle: &observation.root_handle, + raw_basename: &observation.basename, + }; + match physical_facts.entry(raw_identity) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(facts); + } + std::collections::btree_map::Entry::Occupied(entry) => { + if *entry.get() != facts { + return Err(SccmClientDiscoveryError::ConflictingObservation); + } + } + } + + match (root_is_valid, canonical_basename) { + (true, Some(canonical_basename)) => { + let key = CanonicalPhysicalIdentity { + root_handle: &observation.root_handle, + canonical_basename: canonical_basename.clone(), + rotation: rotation_segment(&observation.rotation), + }; + let normalized = NormalizedObservation { + observation, + logical_artifact_ids: logical_artifact_ids(&canonical_basename), + canonical_basename, + }; + match observations.entry(key) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(normalized); + } + std::collections::btree_map::Entry::Occupied(mut entry) => { + if entry.get().observation.state != observation.state { + return Err(SccmClientDiscoveryError::ConflictingObservation); + } + if compare_observation_order(&normalized, entry.get()) == Ordering::Less { + entry.insert(normalized); + } + } + } + } + (rejected_root_is_valid, catalog_basename) => { + let coverage_issue = + coverage_issue_key(rejected_root_is_valid, catalog_basename.as_deref()); + add_coverage_issue_count(&mut coverage_issue_counts, coverage_issue, 1); + } + } + } + let mut observations = observations.into_values().collect::>(); + observations.sort_by(compare_observation_order); + debug_assert!(coverage_issue_counts.len() <= MAX_SCCM_CLIENT_DISCOVERY_COVERAGE_ISSUES); + Ok(NormalizedDiscovery { + observations, + coverage_issue_counts, + }) +} + +fn coverage_issue_key(root_is_valid: bool, catalog_basename: Option<&str>) -> CoverageIssueKey { + let state = if root_is_valid { + SccmClientDiscoveryCoverageIssueState::Unsupported + } else { + SccmClientDiscoveryCoverageIssueState::InvalidProvenance + }; + let catalog_entry_id = catalog_basename + .map(catalog_entry_id) + .unwrap_or_else(|| NO_CATALOG_ENTRY_ID.to_owned()); + CoverageIssueKey { + catalog_entry_id, + logical_artifact_ids: Vec::new(), + rotation_category: SccmClientDiscoveryRotationCategory::Unknown, + state, + omitted_declaration_state: None, + } +} + +fn declaration_limit_issue_key( + omitted_declaration_state: SccmClientDiscoveryState, +) -> CoverageIssueKey { + CoverageIssueKey { + catalog_entry_id: NO_CATALOG_ENTRY_ID.to_owned(), + logical_artifact_ids: Vec::new(), + rotation_category: SccmClientDiscoveryRotationCategory::Unknown, + state: SccmClientDiscoveryCoverageIssueState::DeclarationLimitExceeded, + omitted_declaration_state: Some(omitted_declaration_state), + } +} + +const _: () = assert!(MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS > 0); +const _: () = assert!(MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS <= u16::MAX as usize); + +fn add_coverage_issue_count( + counts: &mut BTreeMap, + key: CoverageIssueKey, + additional_count: usize, +) { + let additional_count = + u16::try_from(additional_count).expect("admitted discovery issue count fits in u16"); + let count = counts.entry(key).or_insert(0_u16); + *count = count + .checked_add(additional_count) + .expect("aggregated discovery issue count fits in u16"); +} + +fn coverage_issue_from_key( + key: CoverageIssueKey, + occurrence_count: u16, +) -> SccmClientDiscoveryCoverageIssue { + let CoverageIssueKey { + catalog_entry_id, + logical_artifact_ids, + rotation_category, + state, + omitted_declaration_state, + } = key; + assert!( + logical_artifact_ids.is_empty(), + "coverage issues cannot assert workflow membership" + ); + let artifact_id = coverage_issue_id( + &catalog_entry_id, + rotation_category, + state, + omitted_declaration_state, + ); + SccmClientDiscoveryCoverageIssue { + artifact_id, + catalog_entry_id, + logical_artifact_ids, + rotation_category, + state, + omitted_declaration_state, + occurrence_count: NonZeroU16::new(occurrence_count) + .expect("every coverage issue represents an admitted observation"), + } +} + +fn classify_observation_source(basename: &str, rotation: &SccmRotation) -> Option { + #[cfg(test)] + CLASSIFIER_INVOCATIONS.with(|count| count.set(count.get() + 1)); + canonical_client_source(basename, rotation) +} + +fn coverage_issue_id( + catalog_entry_id: &str, + rotation_category: SccmClientDiscoveryRotationCategory, + state: SccmClientDiscoveryCoverageIssueState, + omitted_declaration_state: Option, +) -> String { + let rotation = match rotation_category { + SccmClientDiscoveryRotationCategory::Current => "current", + SccmClientDiscoveryRotationCategory::LoUnderscore => "lo", + SccmClientDiscoveryRotationCategory::Numbered => "numbered", + SccmClientDiscoveryRotationCategory::Timestamped => "timestamped", + SccmClientDiscoveryRotationCategory::Unknown => "unknown", + }; + let state = match state { + SccmClientDiscoveryCoverageIssueState::InvalidProvenance => "invalid-provenance", + SccmClientDiscoveryCoverageIssueState::Unsupported => "unsupported", + SccmClientDiscoveryCoverageIssueState::DeclarationLimitExceeded => { + "declaration-limit-exceeded" + } + }; + let omitted_state = omitted_declaration_state.map(|state| match state { + SccmClientDiscoveryState::Discovered => "discovered", + SccmClientDiscoveryState::AccessDenied => "access-denied", + SccmClientDiscoveryState::NotFound => "not-found", + SccmClientDiscoveryState::Capped => "capped", + SccmClientDiscoveryState::Skipped => "skipped", + }); + let value = match omitted_state { + Some(omitted_state) => format!( + "cmtraceopen.sccm.discovery.coverage.v1\0{catalog_entry_id}\0{rotation}\0{state}\0{omitted_state}" + ), + None => format!( + "cmtraceopen.sccm.discovery.coverage.v1\0{catalog_entry_id}\0{rotation}\0{state}" + ), + }; + format!( + "sccm-discovery-coverage:v1:sha256:{}", + sha256_bytes(value.as_bytes()) + ) +} + +fn selection_state( + observation: &NormalizedObservation<'_>, + max_found_fragments_per_source: usize, + found_per_source: &mut BTreeMap<(String, String), usize>, + capped_sources: &mut BTreeSet<(String, String)>, +) -> Option { + let source_key = ( + observation.observation.root_handle.clone(), + observation.canonical_basename.clone(), + ); + Some(match observation.observation.state { + SccmClientDiscoveryObservationState::Found => { + let count = found_per_source.entry(source_key.clone()).or_default(); + if *count < max_found_fragments_per_source { + *count += 1; + SccmClientDiscoveryState::Discovered + } else if capped_sources.insert(source_key) { + SccmClientDiscoveryState::Capped + } else { + return None; + } + } + SccmClientDiscoveryObservationState::AccessDenied => SccmClientDiscoveryState::AccessDenied, + SccmClientDiscoveryObservationState::NotFound => SccmClientDiscoveryState::NotFound, + SccmClientDiscoveryObservationState::Skipped => SccmClientDiscoveryState::Skipped, + }) +} + +fn candidate_from_observation(observation: &NormalizedObservation<'_>) -> Option { + #[cfg(test)] + CANDIDATE_CONSTRUCTIONS.with(|count| count.set(count.get() + 1)); + let source_digest = source_identity_digest( + &observation.observation.root_handle, + &observation.canonical_basename, + )?; + + let mut physical_observation = observation.observation.clone(); + physical_observation.basename = physical_basename( + &observation.canonical_basename, + &physical_observation.rotation, + ); + Some(Candidate { + observation: physical_observation, + catalog_entry_id: catalog_entry_id(&observation.canonical_basename), + logical_artifact_ids: observation.logical_artifact_ids.clone(), + source_digest, + }) +} + +fn declaration_from_candidate( + candidate: Candidate, + state: SccmClientDiscoveryState, +) -> SccmClientDiscoveryDeclaration { + #[cfg(test)] + DECLARATION_CONSTRUCTIONS.with(|count| count.set(count.get() + 1)); + + let path_fingerprint = format!("sha256:{}", candidate.source_digest); + let artifact_id = match state { + SccmClientDiscoveryState::Discovered => expected_physical_artifact_id( + &path_fingerprint, + &candidate.observation.rotation, + &candidate.observation.basename, + ), + SccmClientDiscoveryState::AccessDenied => marker_id( + &candidate.catalog_entry_id, + SccmManifestSourceState::AccessDenied, + &candidate.observation.rotation, + &candidate.observation.basename, + &path_fingerprint, + ), + SccmClientDiscoveryState::NotFound => marker_id( + &candidate.catalog_entry_id, + SccmManifestSourceState::Absent, + &candidate.observation.rotation, + &candidate.observation.basename, + &path_fingerprint, + ), + SccmClientDiscoveryState::Capped => marker_id( + &candidate.catalog_entry_id, + SccmManifestSourceState::Capped, + &candidate.observation.rotation, + &candidate.observation.basename, + &path_fingerprint, + ), + SccmClientDiscoveryState::Skipped => marker_id( + &candidate.catalog_entry_id, + SccmManifestSourceState::Skipped, + &candidate.observation.rotation, + &candidate.observation.basename, + &path_fingerprint, + ), + }; + SccmClientDiscoveryDeclaration { + evidence_identity: evidence_id( + &candidate.catalog_entry_id, + &candidate.source_digest, + &candidate.observation.rotation, + &candidate.observation.basename, + ), + catalog_entry_id: candidate.catalog_entry_id, + logical_artifact_ids: candidate.logical_artifact_ids, + artifact_id, + path_fingerprint, + root_handle: candidate.observation.root_handle, + basename: candidate.observation.basename, + rotation: candidate.observation.rotation, + state, + } +} + +fn marker_id( + catalog_entry_id: &str, + state: SccmManifestSourceState, + rotation: &SccmRotation, + basename: &str, + path_fingerprint: &str, +) -> String { + expected_marker_artifact_id( + catalog_entry_id, + state, + rotation, + basename, + Some(path_fingerprint), + ) +} + +fn evidence_id( + catalog_entry_id: &str, + source_digest: &str, + rotation: &SccmRotation, + basename: &str, +) -> String { + let value = format!( + "cmtraceopen.sccm.evidence.v1\0{catalog_entry_id}\0{source_digest}\0{}\0{basename}", + rotation_segment(rotation) + ); + format!("sccm-evidence:v1:sha256:{}", sha256_bytes(value.as_bytes())) +} + +fn compare_observation_order( + left: &NormalizedObservation<'_>, + right: &NormalizedObservation<'_>, +) -> Ordering { + left.logical_artifact_ids + .cmp(&right.logical_artifact_ids) + .then_with(|| { + left.observation + .root_handle + .cmp(&right.observation.root_handle) + }) + .then_with(|| rotation_order(&left.observation.rotation, &right.observation.rotation)) + .then_with(|| left.observation.basename.cmp(&right.observation.basename)) + .then_with(|| state_rank(left.observation.state).cmp(&state_rank(right.observation.state))) +} + +fn logical_artifact_ids(canonical_basename: &str) -> Vec { + #[cfg(test)] + LOGICAL_ARTIFACT_ID_LOOKUPS.with(|count| count.set(count.get() + 1)); + logical_artifact_ids_for_basename(canonical_basename) +} + +fn physical_basename(canonical_basename: &str, rotation: &SccmRotation) -> String { + match rotation { + SccmRotation::Current => canonical_basename.to_owned(), + SccmRotation::LoUnderscore => { + let stem = canonical_basename + .strip_suffix(".log") + .expect("prevalidated lo_ rotation has a canonical log basename"); + format!("{stem}.lo_") + } + SccmRotation::Numbered(number) => format!("{canonical_basename}.{number}"), + SccmRotation::Timestamped(timestamp) => format!("{canonical_basename}.{timestamp}"), + SccmRotation::Unknown(_) => unreachable!("supported observation has a known rotation"), + } +} + +fn state_rank(state: SccmClientDiscoveryObservationState) -> u8 { + match state { + SccmClientDiscoveryObservationState::Found => 0, + SccmClientDiscoveryObservationState::AccessDenied => 1, + SccmClientDiscoveryObservationState::NotFound => 2, + SccmClientDiscoveryObservationState::Skipped => 3, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_A: &str = "root-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const ROOT_B: &str = "root-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn observation( + root_handle: &str, + basename: String, + rotation: SccmRotation, + ) -> SccmClientDiscoveryObservation { + SccmClientDiscoveryObservation { + root_handle: root_handle.to_owned(), + basename, + rotation, + state: SccmClientDiscoveryObservationState::Found, + } + } + + fn unsupported_rotation(suffix: &str) -> SccmRotation { + SccmRotation::Unknown(cmtraceopen_parser::sccm::SccmUnknownRotation { + kind: "filenameSuffix".to_owned(), + value: Some(serde_json::Value::String(suffix.to_owned())), + }) + } + + fn construction_counts() -> (usize, usize) { + ( + CANDIDATE_CONSTRUCTIONS.with(Cell::get), + DECLARATION_CONSTRUCTIONS.with(Cell::get), + ) + } + + fn reset_construction_counts() { + CANDIDATE_CONSTRUCTIONS.with(|count| count.set(0)); + DECLARATION_CONSTRUCTIONS.with(|count| count.set(0)); + } + + fn normalization_count() -> usize { + NORMALIZATION_OPERATIONS.with(Cell::get) + } + + fn reset_normalization_count() { + NORMALIZATION_OPERATIONS.with(|count| count.set(0)); + } + + fn logical_artifact_id_lookup_count() -> usize { + LOGICAL_ARTIFACT_ID_LOOKUPS.with(Cell::get) + } + + fn reset_logical_artifact_id_lookup_count() { + LOGICAL_ARTIFACT_ID_LOOKUPS.with(|count| count.set(0)); + } + + fn consistency_key_count() -> usize { + CONSISTENCY_KEY_CONSTRUCTIONS.with(Cell::get) + } + + fn reset_consistency_key_count() { + CONSISTENCY_KEY_CONSTRUCTIONS.with(|count| count.set(0)); + } + + fn classifier_invocation_count() -> usize { + CLASSIFIER_INVOCATIONS.with(Cell::get) + } + + fn consistency_comparison_count() -> usize { + CONSISTENCY_COMPARISONS.with(Cell::get) + } + + fn reset_classification_work_counts() { + CLASSIFIER_INVOCATIONS.with(|count| count.set(0)); + CONSISTENCY_COMPARISONS.with(|count| count.set(0)); + } + + fn comparison_budget(observation_count: usize) -> usize { + let log_bound = + usize::BITS as usize - observation_count.saturating_sub(1).leading_zeros() as usize; + observation_count + .saturating_mul(log_bound.saturating_add(2)) + .saturating_mul(4) + } + + #[test] + fn defensive_observation_limit_rejects_before_any_normalization_or_construction() { + let observations = (1..=MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS + 1) + .map(|number| { + observation( + ROOT_A, + format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number as u32), + ) + }) + .collect(); + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }; + + reset_construction_counts(); + reset_normalization_count(); + reset_consistency_key_count(); + reset_classification_work_counts(); + assert_eq!( + discover_client_sources(&input), + Err(SccmClientDiscoveryError::ObservationLimitExceeded), + "input beyond the defensive discovery contract must fail conservatively" + ); + assert_eq!( + normalization_count(), + 0, + "the defensive limit rejects before any observation is normalized" + ); + assert_eq!( + construction_counts(), + (0, 0), + "the defensive limit rejects before candidates or declarations are built" + ); + assert_eq!( + consistency_key_count(), + 0, + "the defensive limit rejects before ephemeral consistency keys are built" + ); + assert_eq!(classifier_invocation_count(), 0); + assert_eq!(consistency_comparison_count(), 0); + } + + #[test] + fn defensive_observation_bound_normalizes_each_all_found_or_mixed_state_input_once() { + let all_found = (1..=MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS) + .map(|number| { + observation( + ROOT_A, + format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number as u32), + ) + }) + .collect::>(); + let mixed_states = (1..=MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS) + .map(|number| SccmClientDiscoveryObservation { + root_handle: if number % 2 == 0 { ROOT_A } else { ROOT_B }.to_owned(), + basename: format!("PolicyAgent.log.{number}"), + rotation: SccmRotation::Numbered(number as u32), + state: match number % 3 { + 0 => SccmClientDiscoveryObservationState::Found, + 1 => SccmClientDiscoveryObservationState::AccessDenied, + _ => SccmClientDiscoveryObservationState::NotFound, + }, + }) + .collect::>(); + + for observations in [all_found, mixed_states] { + reset_construction_counts(); + reset_normalization_count(); + reset_consistency_key_count(); + reset_classification_work_counts(); + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }) + .expect("the defensive boundary itself remains processable"); + + assert_eq!( + normalization_count(), + MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS, + "each accepted observation is normalized exactly once" + ); + assert_eq!( + consistency_key_count(), + MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS, + "the bounded consistency pass builds exactly one borrowed key per observation" + ); + assert_eq!( + classifier_invocation_count(), + MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS, + "every admitted observation is classified at most once" + ); + assert!( + consistency_comparison_count() + <= comparison_budget(MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS), + "consistency work must remain O(n log n): {} comparisons exceeded {}", + consistency_comparison_count(), + comparison_budget(MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS), + ); + assert!( + result.declarations.len() <= MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + "the declaration output remains globally bounded" + ); + } + } + + #[test] + fn normalization_caches_logical_artifact_ids_once_per_observation_despite_sorting() { + let observations = (1..=64) + .map(|number| { + observation( + ROOT_A, + format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number), + ) + }) + .collect::>(); + + reset_logical_artifact_id_lookup_count(); + discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 64, + observations, + }) + .expect("accepted observations normalize deterministically"); + + assert_eq!( + logical_artifact_id_lookup_count(), + 64, + "sorting and declaration construction must reuse each normalized observation's cached logical IDs" + ); + } + + #[test] + fn rejected_duplicate_boundary_has_bounded_classification_and_consistency_work() { + let observations = (0..MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS) + .map(|_| SccmClientDiscoveryObservation { + root_handle: "unvalidated-root".to_owned(), + basename: "Unrelated.log.backup".to_owned(), + rotation: unsupported_rotation(".backup"), + state: SccmClientDiscoveryObservationState::Found, + }) + .collect(); + + reset_classification_work_counts(); + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }) + .expect("same-state rejected duplicates remain countable at the admission boundary"); + + assert!(result.declarations.is_empty()); + assert_eq!(result.coverage_issues.len(), 1); + assert_eq!( + result.coverage_issues[0].occurrence_count.get() as usize, + MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS + ); + assert!( + consistency_comparison_count() + <= comparison_budget(MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS), + "rejected consistency work must remain O(n log n): {} comparisons exceeded {}", + consistency_comparison_count(), + comparison_budget(MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS), + ); + assert_eq!( + classifier_invocation_count(), + MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS, + "every rejected observation is classified at most once" + ); + } + + #[test] + fn oversized_discovery_is_rejected_without_constructing_candidates_or_declarations() { + let mut observations = Vec::new(); + for number in 1..=6_000 { + observations.push(observation( + ROOT_A, + format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number), + )); + observations.push(observation( + ROOT_B, + format!("PolicyAgent.log.{number}"), + SccmRotation::Numbered(number), + )); + } + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }; + + reset_construction_counts(); + reset_normalization_count(); + let error = discover_client_sources(&input) + .expect_err("inputs outside the defensive contract must be rejected"); + let counts = construction_counts(); + + let mut reversed = input.clone(); + reversed.observations.reverse(); + reset_construction_counts(); + reset_normalization_count(); + let reversed_error = discover_client_sources(&reversed) + .expect_err("input order does not weaken the defensive limit"); + let reversed_counts = construction_counts(); + + assert_eq!( + error, reversed_error, + "input order must not change conservative overflow behavior" + ); + for (candidate_constructions, declaration_constructions) in [counts, reversed_counts] { + assert_eq!( + (candidate_constructions, declaration_constructions), + (0, 0), + "the defensive limit rejects before candidates or declarations are built" + ); + } + } + + #[test] + fn per_source_cap_does_not_let_an_early_noisy_source_starve_later_sources() { + let mut observations = (1..=MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS - 4) + .map(|number| { + observation( + ROOT_A, + format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number as u32), + ) + }) + .collect::>(); + observations.splice( + 0..0, + [ + observation(ROOT_A, "AppEnforce.log".to_owned(), SccmRotation::Current), + observation( + ROOT_A, + "AppEnforce.lo_".to_owned(), + SccmRotation::LoUnderscore, + ), + ], + ); + observations.push(observation( + ROOT_B, + "PolicyAgent.log".to_owned(), + SccmRotation::Current, + )); + observations.push(observation( + ROOT_B, + "PolicyAgent.lo_".to_owned(), + SccmRotation::LoUnderscore, + )); + + reset_construction_counts(); + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 2, + observations, + }) + .expect("valid observations"); + let (candidate_constructions, declaration_constructions) = construction_counts(); + + assert_eq!( + result + .declarations + .iter() + .filter(|declaration| declaration.root_handle == ROOT_A) + .map(|declaration| (&declaration.rotation, declaration.state)) + .collect::>(), + vec![ + (&SccmRotation::Current, SccmClientDiscoveryState::Discovered), + ( + &SccmRotation::LoUnderscore, + SccmClientDiscoveryState::Discovered + ), + (&SccmRotation::Numbered(1), SccmClientDiscoveryState::Capped), + ] + ); + assert!(result.declarations.iter().any(|declaration| { + declaration.root_handle == ROOT_B + && declaration.rotation == SccmRotation::Current + && declaration.state == SccmClientDiscoveryState::Discovered + })); + assert!( + result.declarations.len() <= MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS + && candidate_constructions <= MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS + && declaration_constructions <= MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS + ); + } +} diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs new file mode 100644 index 000000000..c77ee7eb6 --- /dev/null +++ b/src-tauri/src/sccm/manifest.rs @@ -0,0 +1,1077 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::File; +use std::io::Read; +use std::path::{Component, Path}; + +use chrono::DateTime; +use cmtraceopen_parser::sccm::{ + assess_client_intake, SccmArtifact, SccmClientIntakeArtifact, SccmClientIntakeBundle, + SccmClientIntakeCaptureGap, SccmRole, SccmRotation, SCCM_DIAGNOSTICS_SCHEMA_VERSION, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::error::AppError; + +use super::contract::{ + canonical_client_source, catalog_entry_id, compare_manifest_artifacts, + compare_manifest_capture_gaps, expected_bundle_group, expected_marker_artifact_id, + expected_physical_artifact_id, is_sha256_digest, is_versioned_handle, + logical_artifact_ids_for_basename, root_handle_digest, rotation_segment, sha256_bytes, + source_identity_digest, SccmBundleManifestV1, SccmManifestArtifact, SccmManifestCaptureGap, + SccmManifestCoverageScope, SccmManifestProvenance, SccmManifestProvenanceProfile, + SccmManifestSourceState, MAX_SCCM_MANIFEST_ARTIFACTS, MAX_SCCM_MANIFEST_BYTES, + SCCM_CLIENT_SOURCE_CATALOG_VERSION, SCCM_MANIFEST_FILE_NAME, SCCM_MANIFEST_VERSION, +}; +use super::private_fs::{is_reparse_point, verify_bundle_root, VerifiedBundleRoot}; + +const LEGACY_MANIFEST_FILE_NAME: &str = "manifest.json"; +const LEGACY_GENERIC_PROFILE_NAME: &str = "cmtrace-full-diagnostics-v1"; +const LEGACY_GENERIC_PROFILE_VERSION: &str = "1.1.0"; +const LEGACY_CONFIGMGR_CCM_LOGS_ID: &str = "configmgr-ccm-logs"; +const LEGACY_CONFIGMGR_LOG_CATEGORY: &str = "logs"; +const LEGACY_CCMSETUP_BASENAME: &str = "ccmsetup.log"; +const MAX_SAFE_TEXT_CHARS: usize = 160; +const MAX_SCCM_CLIENT_PHYSICAL_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024; +const MAX_SCCM_CLIENT_TOTAL_PHYSICAL_BYTES: u64 = 1024 * 1024 * 1024; + +#[cfg(all(test, unix))] +thread_local! { + static INTAKE_PROJECTION_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(all(test, unix))] +fn reset_intake_projection_count() { + INTAKE_PROJECTION_COUNT.with(|count| count.set(0)); +} + +#[cfg(all(test, unix))] +fn intake_projection_count() -> usize { + INTAKE_PROJECTION_COUNT.with(std::cell::Cell::get) +} + +enum ValidatedManifestRead { + Native { + manifest: SccmBundleManifestV1, + intake_bundle: SccmClientIntakeBundle, + }, + Legacy(SccmBundleManifestV1), +} + +fn read_validated_manifest_or_legacy( + bundle_root: &Path, +) -> Result { + let verified_root = verify_bundle_root(bundle_root)?; + match verified_root.open_relative_file(Path::new(SCCM_MANIFEST_FILE_NAME)) { + Ok(input) => { + let bytes = read_bounded_file(input, MAX_SCCM_MANIFEST_BYTES, "SCCM manifest")?; + let manifest = + serde_json::from_slice::(&bytes).map_err(|error| { + AppError::Parse { + file: SCCM_MANIFEST_FILE_NAME.to_owned(), + reason: error.to_string(), + } + })?; + let intake_bundle = validate_native_manifest(&verified_root, &manifest)?; + Ok(ValidatedManifestRead::Native { + manifest, + intake_bundle, + }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + read_legacy_manifest(&verified_root).map(ValidatedManifestRead::Legacy) + } + Err(_) => Err(AppError::InvalidInput( + "SCCM manifest cannot be opened safely".to_owned(), + )), + } +} + +pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result { + match read_validated_manifest_or_legacy(bundle_root)? { + ValidatedManifestRead::Native { manifest, .. } + | ValidatedManifestRead::Legacy(manifest) => Ok(manifest), + } +} + +fn manifest_to_client_intake_bundle( + manifest: &SccmBundleManifestV1, +) -> Result { + #[cfg(all(test, unix))] + INTAKE_PROJECTION_COUNT.with(|count| count.set(count.get().saturating_add(1))); + + validate_native_manifest_structure(manifest)?; + let artifacts = manifest + .artifacts + .iter() + .map(|source| SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: source.artifact_id.clone(), + display_name: source.basename.clone(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: source.configmgr_version.clone(), + collected_at_utc: source.collected_at_utc.clone(), + rotation: source.rotation.clone(), + coverage: source.state.pure_coverage(), + encoding: source.encoding.clone(), + }, + path_fingerprint: source.path_fingerprint.clone(), + rotation_lineage: source.rotation_lineage.clone(), + relative_path: source.relative_path.clone(), + fragment_complete: Some(source.fragment_complete), + declared_byte_length: None, + content_sha256: None, + }) + .collect(); + let capture_gaps = manifest + .capture_gaps + .iter() + .map(|gap| SccmClientIntakeCaptureGap { + artifact_id: gap.artifact_id.clone(), + basename: gap.basename.clone(), + rotation: gap.rotation.clone(), + coverage: gap.state.pure_coverage(), + path_fingerprint: gap.path_fingerprint.clone(), + rotation_lineage: gap.rotation_lineage.clone(), + }) + .collect(); + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps, + }; + assess_client_intake(&bundle).map_err(|error| { + AppError::InvalidInput(format!( + "SCCM manifest cannot be converted to the pure client intake contract: {error}" + )) + })?; + Ok(bundle) +} + +pub fn read_sccm_client_intake_bundle( + bundle_root: &Path, +) -> Result { + match read_validated_manifest_or_legacy(bundle_root)? { + ValidatedManifestRead::Native { intake_bundle, .. } => Ok(intake_bundle), + ValidatedManifestRead::Legacy(_) => { + let verified_root = verify_bundle_root(bundle_root)?; + read_legacy_client_intake_bundle(&verified_root) + } + } +} + +fn validate_native_manifest( + bundle_root: &VerifiedBundleRoot, + manifest: &SccmBundleManifestV1, +) -> Result { + let intake_bundle = manifest_to_client_intake_bundle(manifest)?; + validate_physical_source_limits(manifest)?; + for artifact in &manifest.artifacts { + if artifact.state.is_physical() { + validate_evidence_file(bundle_root, artifact)?; + } + } + Ok(intake_bundle) +} + +fn validate_physical_source_limits(manifest: &SccmBundleManifestV1) -> Result<(), AppError> { + let mut totals = BTreeMap::::new(); + let mut total_physical_bytes = 0_u64; + for artifact in manifest + .artifacts + .iter() + .filter(|artifact| artifact.state.is_physical()) + { + add_to_client_physical_byte_budget(artifact.bytes_copied, &mut total_physical_bytes)?; + let canonical_basename = canonical_client_source(&artifact.basename, &artifact.rotation) + .expect("physical artifacts were catalog-validated before source limits"); + let source = source_identity_digest( + artifact + .root_handle + .as_deref() + .expect("physical artifacts have validated root provenance"), + &canonical_basename, + ) + .expect("physical artifacts have a validated root handle"); + let entry = totals.entry(source).or_insert((0, 0)); + entry.0 = entry + .0 + .checked_add(1) + .ok_or_else(|| AppError::InvalidInput("SCCM source file cap is exceeded".to_owned()))?; + entry.1 = entry + .1 + .checked_add(artifact.bytes_copied) + .ok_or_else(|| AppError::InvalidInput("SCCM source byte cap is exceeded".to_owned()))?; + if entry.0 > manifest.max_files_per_source as u64 { + return Err(AppError::InvalidInput( + "SCCM source file cap is exceeded".to_owned(), + )); + } + if entry.1 > manifest.max_bytes_per_source { + return Err(AppError::InvalidInput( + "SCCM source byte cap is exceeded".to_owned(), + )); + } + } + Ok(()) +} + +fn add_to_client_physical_byte_budget( + artifact_bytes: u64, + total_physical_bytes: &mut u64, +) -> Result<(), AppError> { + if artifact_bytes > MAX_SCCM_CLIENT_PHYSICAL_ARTIFACT_BYTES { + return Err(AppError::InvalidInput( + "SCCM physical artifact byte cap is exceeded".to_owned(), + )); + } + *total_physical_bytes = total_physical_bytes + .checked_add(artifact_bytes) + .ok_or_else(|| { + AppError::InvalidInput("SCCM aggregate physical byte cap is exceeded".to_owned()) + })?; + if *total_physical_bytes > MAX_SCCM_CLIENT_TOTAL_PHYSICAL_BYTES { + return Err(AppError::InvalidInput( + "SCCM aggregate physical byte cap is exceeded".to_owned(), + )); + } + Ok(()) +} + +fn validate_native_manifest_structure(manifest: &SccmBundleManifestV1) -> Result<(), AppError> { + if manifest.sccm_manifest_version != SCCM_MANIFEST_VERSION + || manifest.diagnostics_schema_version != SCCM_DIAGNOSTICS_SCHEMA_VERSION + || manifest.source_catalog_version != SCCM_CLIENT_SOURCE_CATALOG_VERSION + || manifest.provenance != SccmManifestProvenance::NativeClientCapture + || manifest.provenance_profile != SccmManifestProvenanceProfile::HmacSha256V1 + { + return Err(AppError::InvalidInput( + "unsupported or non-native SCCM client manifest contract".to_owned(), + )); + } + if manifest + .artifacts + .len() + .saturating_add(manifest.capture_gaps.len()) + > MAX_SCCM_MANIFEST_ARTIFACTS + { + return Err(AppError::InvalidInput( + "SCCM manifest has too many artifacts or capture gaps".to_owned(), + )); + } + if manifest.max_files_per_source == 0 + || manifest.max_bytes_per_source == 0 + || manifest + .collected_at_utc + .as_deref() + .is_none_or(|value| !is_utc_rfc3339(value)) + || manifest + .host_handle + .as_deref() + .is_some_and(|value| !is_versioned_handle(value, "cmtraceopen.host.hmac-sha256.v1:")) + { + return Err(AppError::InvalidInput( + "SCCM manifest context is malformed or privacy-unsafe".to_owned(), + )); + } + + let mut artifact_ids = BTreeSet::new(); + let mut relative_paths = BTreeSet::new(); + let mut lineage_bindings = BTreeMap::::new(); + for artifact in &manifest.artifacts { + validate_native_artifact(manifest, artifact)?; + if !artifact_ids.insert(artifact.artifact_id.to_ascii_lowercase()) { + return Err(AppError::InvalidInput( + "SCCM manifest contains duplicate artifact IDs".to_owned(), + )); + } + if let Some(relative_path) = &artifact.relative_path { + if !relative_paths.insert(relative_path.to_ascii_lowercase()) { + return Err(AppError::InvalidInput( + "SCCM manifest contains duplicate relative paths".to_owned(), + )); + } + } + if let (Some(lineage), Some(fingerprint)) = + (&artifact.rotation_lineage, &artifact.path_fingerprint) + { + bind_lineage( + &mut lineage_bindings, + lineage, + &canonical_basename(&artifact.basename), + fingerprint, + "SCCM rotation lineage crosses physical sources", + )?; + } + } + for gap in &manifest.capture_gaps { + validate_capture_gap(gap)?; + if !artifact_ids.insert(gap.artifact_id.to_ascii_lowercase()) { + return Err(AppError::InvalidInput( + "SCCM manifest contains duplicate artifact IDs".to_owned(), + )); + } + bind_lineage( + &mut lineage_bindings, + &gap.rotation_lineage, + &canonical_basename(&gap.basename), + &gap.path_fingerprint, + "SCCM capture gap lineage crosses physical sources", + )?; + } + if !manifest + .artifacts + .windows(2) + .all(|pair| compare_manifest_artifacts(&pair[0], &pair[1]).is_le()) + { + return Err(AppError::InvalidInput( + "SCCM manifest artifacts are not in deterministic order".to_owned(), + )); + } + if !manifest + .capture_gaps + .windows(2) + .all(|pair| compare_manifest_capture_gaps(&pair[0], &pair[1]).is_le()) + { + return Err(AppError::InvalidInput( + "SCCM manifest capture gaps are not in deterministic order".to_owned(), + )); + } + Ok(()) +} + +fn bind_lineage( + bindings: &mut BTreeMap, + lineage: &str, + basename: &str, + fingerprint: &str, + error_message: &str, +) -> Result<(), AppError> { + let binding = (basename.to_owned(), fingerprint.to_owned()); + if bindings + .insert(lineage.to_owned(), binding.clone()) + .is_some_and(|existing| existing != binding) + { + return Err(AppError::InvalidInput(error_message.to_owned())); + } + Ok(()) +} + +fn validate_native_artifact( + manifest: &SccmBundleManifestV1, + artifact: &SccmManifestArtifact, +) -> Result<(), AppError> { + if artifact.role != SccmRole::Client + || !is_versioned_handle(&artifact.artifact_id, "sccm-artifact:v1:sha256:") + || artifact.collected_at_utc != manifest.collected_at_utc + || artifact + .configmgr_version + .as_deref() + .is_some_and(|value| !is_safe_configmgr_version(value)) + || artifact + .encoding + .as_deref() + .is_some_and(|value| !is_supported_encoding(value)) + { + return Err(AppError::InvalidInput( + "SCCM manifest artifact identity, role, time, or encoding is invalid".to_owned(), + )); + } + let canonical_basename = canonical_client_source(&artifact.basename, &artifact.rotation) + .ok_or_else(|| { + AppError::InvalidInput( + "SCCM manifest artifact is not bound to the authoritative catalog".to_owned(), + ) + })?; + if artifact.catalog_entry_id != catalog_entry_id(&canonical_basename) + || artifact.logical_artifact_ids != logical_artifact_ids_for_basename(&canonical_basename) + || artifact.logical_artifact_ids.is_empty() + { + return Err(AppError::InvalidInput( + "SCCM manifest artifact memberships are incomplete or unordered".to_owned(), + )); + } + + if artifact.state.is_physical() { + if artifact.coverage_scope != SccmManifestCoverageScope::Source { + return Err(AppError::InvalidInput( + "physical SCCM evidence cannot claim a root-enumeration scope".to_owned(), + )); + } + validate_bound_provenance(artifact, &canonical_basename)?; + let fingerprint = artifact + .path_fingerprint + .as_deref() + .expect("validated physical provenance includes a fingerprint"); + if artifact.artifact_id + != expected_physical_artifact_id(fingerprint, &artifact.rotation, &artifact.basename) + { + return Err(AppError::InvalidInput( + "physical SCCM artifact provenance is malformed".to_owned(), + )); + } + validate_relative_path(artifact, &canonical_basename)?; + if artifact + .content_sha256 + .as_deref() + .is_none_or(|digest| !is_sha256_digest(digest)) + { + return Err(AppError::InvalidInput( + "physical SCCM artifact has no valid content digest".to_owned(), + )); + } + if artifact.state == SccmManifestSourceState::Capped + || (artifact.state == SccmManifestSourceState::ParseFailed + && artifact.limit_applied.is_some()) + { + if artifact.fragment_complete + || artifact.capture_limit_kind.is_none() + || artifact.limit_applied != Some(artifact.bytes_copied) + { + return Err(AppError::InvalidInput( + "bounded SCCM artifact has incoherent limit or completeness".to_owned(), + )); + } + } else if artifact.limit_applied.is_some() || artifact.capture_limit_kind.is_some() { + return Err(AppError::InvalidInput( + "uncapped SCCM artifact declares a capture limit".to_owned(), + )); + } + } else { + if artifact.relative_path.is_some() + || artifact.bytes_copied != 0 + || artifact.limit_applied.is_some() + || artifact.capture_limit_kind.is_some() + || artifact.content_sha256.is_some() + || artifact.fragment_complete + { + return Err(AppError::InvalidInput( + "nonphysical SCCM coverage marker claims physical evidence".to_owned(), + )); + } + validate_nonphysical_provenance(artifact, &canonical_basename)?; + if artifact.artifact_id + != expected_marker_artifact_id( + &artifact.catalog_entry_id, + artifact.state, + &artifact.rotation, + &artifact.basename, + artifact.path_fingerprint.as_deref(), + ) + { + return Err(AppError::InvalidInput( + "SCCM coverage marker identity is not canonical".to_owned(), + )); + } + } + Ok(()) +} + +fn validate_nonphysical_provenance( + artifact: &SccmManifestArtifact, + canonical_basename: &str, +) -> Result<(), AppError> { + if artifact.coverage_scope == SccmManifestCoverageScope::RootEnumeration { + if !matches!( + artifact.state, + SccmManifestSourceState::AccessDenied | SccmManifestSourceState::FailedUnknownDetail + ) || artifact.rotation != SccmRotation::Current + { + return Err(AppError::InvalidInput( + "SCCM root-enumeration coverage scope is incoherent".to_owned(), + )); + } + return validate_enumeration_provenance(artifact, canonical_basename); + } + let has_any_provenance = artifact.source_handle.is_some() + || artifact.root_handle.is_some() + || artifact.path_fingerprint.is_some() + || artifact.rotation_lineage.is_some(); + if has_any_provenance { + validate_bound_provenance(artifact, canonical_basename)?; + } + Ok(()) +} + +fn validate_capture_gap(gap: &SccmManifestCaptureGap) -> Result<(), AppError> { + if !matches!( + gap.state, + SccmManifestSourceState::Capped | SccmManifestSourceState::ParseFailed + ) || gap.bytes_retained != 0 + || !is_versioned_handle(&gap.artifact_id, "sccm-artifact:v1:sha256:") + { + return Err(AppError::InvalidInput( + "SCCM capture gap state or payload claim is incoherent".to_owned(), + )); + } + let canonical_basename = + canonical_client_source(&gap.basename, &gap.rotation).ok_or_else(|| { + AppError::InvalidInput( + "SCCM capture gap is not bound to the authoritative catalog".to_owned(), + ) + })?; + if gap.catalog_entry_id != catalog_entry_id(&canonical_basename) + || gap.logical_artifact_ids != logical_artifact_ids_for_basename(&canonical_basename) + || gap.logical_artifact_ids.is_empty() + { + return Err(AppError::InvalidInput( + "SCCM capture gap is not bound to the authoritative catalog".to_owned(), + )); + } + let expected_source_digest = source_identity_digest(&gap.root_handle, &canonical_basename) + .ok_or_else(|| AppError::InvalidInput("SCCM root handle is malformed".to_owned()))?; + let expected_lineage = sha256_bytes(format!("lineage:v1:{expected_source_digest}").as_bytes()); + if gap + .source_handle + .strip_prefix("cmtraceopen.source.sha256.v1:") + != Some(expected_source_digest.as_str()) + || gap.path_fingerprint.strip_prefix("sha256:") != Some(expected_source_digest.as_str()) + || gap + .rotation_lineage + .strip_prefix("cmtraceopen.lineage.sha256.v1:") + != Some(expected_lineage.as_str()) + || gap.artifact_id + != expected_marker_artifact_id( + &gap.catalog_entry_id, + gap.state, + &gap.rotation, + &gap.basename, + Some(&gap.path_fingerprint), + ) + { + return Err(AppError::InvalidInput( + "SCCM capture gap provenance is malformed".to_owned(), + )); + } + Ok(()) +} + +fn validate_bound_provenance( + artifact: &SccmManifestArtifact, + canonical_basename: &str, +) -> Result<(), AppError> { + let source_handle = required_provenance(&artifact.source_handle)?; + let root_handle = required_provenance(&artifact.root_handle)?; + let fingerprint = required_provenance(&artifact.path_fingerprint)?; + let lineage = required_provenance(&artifact.rotation_lineage)?; + let expected_source_digest = source_identity_digest(root_handle, canonical_basename) + .ok_or_else(|| AppError::InvalidInput("SCCM root handle is malformed".to_owned()))?; + let expected_lineage = sha256_bytes(format!("lineage:v1:{expected_source_digest}").as_bytes()); + if source_handle.strip_prefix("cmtraceopen.source.sha256.v1:") + != Some(expected_source_digest.as_str()) + || fingerprint.strip_prefix("sha256:") != Some(expected_source_digest.as_str()) + || lineage.strip_prefix("cmtraceopen.lineage.sha256.v1:") != Some(expected_lineage.as_str()) + { + return Err(AppError::InvalidInput( + "SCCM source provenance is malformed".to_owned(), + )); + } + Ok(()) +} + +fn required_provenance(value: &Option) -> Result<&str, AppError> { + value + .as_deref() + .ok_or_else(|| AppError::InvalidInput("SCCM source provenance is incomplete".to_owned())) +} + +fn validate_enumeration_provenance( + artifact: &SccmManifestArtifact, + canonical_basename: &str, +) -> Result<(), AppError> { + let source_handle = required_provenance(&artifact.source_handle)?; + let root_handle = required_provenance(&artifact.root_handle)?; + let fingerprint = required_provenance(&artifact.path_fingerprint)?; + let lineage = required_provenance(&artifact.rotation_lineage)?; + let root_digest = root_handle_digest(root_handle) + .ok_or_else(|| AppError::InvalidInput("SCCM root handle is malformed".to_owned()))?; + let expected_source_digest = sha256_bytes( + format!("cmtraceopen.sccm.root-enumeration.v1\0{root_digest}\0{canonical_basename}") + .as_bytes(), + ); + let expected_lineage = sha256_bytes(format!("lineage:v1:{expected_source_digest}").as_bytes()); + if source_handle.strip_prefix("cmtraceopen.source.sha256.v1:") + != Some(expected_source_digest.as_str()) + || fingerprint.strip_prefix("sha256:") != Some(expected_source_digest.as_str()) + || lineage.strip_prefix("cmtraceopen.lineage.sha256.v1:") != Some(expected_lineage.as_str()) + { + return Err(AppError::InvalidInput( + "SCCM enumeration provenance is malformed".to_owned(), + )); + } + Ok(()) +} + +fn validate_relative_path( + artifact: &SccmManifestArtifact, + canonical_basename: &str, +) -> Result<(), AppError> { + let relative = artifact.relative_path.as_deref().ok_or_else(|| { + AppError::InvalidInput("physical SCCM artifact has no relative path".to_owned()) + })?; + if relative.len() > 1024 + || relative.contains('\\') + || Path::new(relative).is_absolute() + || Path::new(relative) + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(AppError::InvalidInput( + "SCCM artifact relative path is unsafe".to_owned(), + )); + } + let segments = relative.split('/').collect::>(); + if segments.len() != 7 + || segments[0..3] != ["evidence", "sccm", "client"] + || segments[3] != expected_bundle_group(&artifact.logical_artifact_ids) + || artifact.root_handle.as_deref() != Some(segments[4]) + || segments[5] != rotation_segment(&artifact.rotation) + || segments[6] != artifact.basename + || canonical_client_source(segments[6], &artifact.rotation).as_deref() + != Some(canonical_basename) + { + return Err(AppError::InvalidInput( + "SCCM artifact relative path does not match catalog provenance".to_owned(), + )); + } + Ok(()) +} + +fn validate_evidence_file( + bundle_root: &VerifiedBundleRoot, + artifact: &SccmManifestArtifact, +) -> Result<(), AppError> { + let relative_path = artifact + .relative_path + .as_deref() + .ok_or_else(|| AppError::InvalidInput("physical SCCM artifact has no path".to_owned()))?; + let mut input = bundle_root + .open_relative_file(Path::new(relative_path)) + .map_err(|_| AppError::InvalidInput("SCCM evidence cannot be opened safely".to_owned()))?; + let opened_metadata = input.metadata().map_err(|_| { + AppError::InvalidInput("SCCM evidence metadata cannot be verified".to_owned()) + })?; + if is_reparse_point(&opened_metadata) + || !opened_metadata.is_file() + || opened_metadata.len() != artifact.bytes_copied + { + return Err(AppError::InvalidInput( + "SCCM evidence violates opened-file coherence".to_owned(), + )); + } + let digest = sha256_exact_file(&mut input, artifact.bytes_copied)?; + if artifact.content_sha256.as_deref() != Some(digest.as_str()) { + return Err(AppError::InvalidInput( + "SCCM evidence violates content digest coherence".to_owned(), + )); + } + Ok(()) +} + +fn read_bounded_file(mut input: File, maximum: u64, label: &str) -> Result, AppError> { + let metadata = input + .metadata() + .map_err(|_| AppError::InvalidInput(format!("{label} metadata cannot be verified")))?; + if is_reparse_point(&metadata) || !metadata.is_file() { + return Err(AppError::InvalidInput(format!( + "{label} must be a real file inside the bundle" + ))); + } + if metadata.len() > maximum { + return Err(AppError::InvalidInput(format!( + "{label} exceeds its size limit" + ))); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut input) + .take(maximum.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| AppError::InvalidInput(format!("{label} cannot be read safely")))?; + if bytes.len() as u64 > maximum { + return Err(AppError::InvalidInput(format!( + "{label} exceeds its size limit" + ))); + } + Ok(bytes) +} + +fn sha256_exact_file(input: &mut File, expected_bytes: u64) -> Result { + let mut digest = Sha256::new(); + let mut remaining = expected_bytes; + let mut buffer = [0_u8; 64 * 1024]; + while remaining > 0 { + let requested = usize::try_from(remaining.min(buffer.len() as u64)) + .expect("bounded digest buffer length fits usize"); + let read = input.read(&mut buffer[..requested]).map_err(|_| { + AppError::InvalidInput("SCCM evidence content cannot be verified".to_owned()) + })?; + if read == 0 { + return Err(AppError::InvalidInput( + "SCCM evidence shrank during content verification".to_owned(), + )); + } + digest.update(&buffer[..read]); + remaining -= read as u64; + } + let mut probe = [0_u8; 1]; + if input.read(&mut probe).map_err(|_| { + AppError::InvalidInput("SCCM evidence content cannot be verified".to_owned()) + })? != 0 + { + return Err(AppError::InvalidInput( + "SCCM evidence grew during content verification".to_owned(), + )); + } + Ok(digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn read_legacy_manifest( + bundle_root: &VerifiedBundleRoot, +) -> Result { + let legacy = read_legacy_value(bundle_root)?; + let values = legacy + .get("artifacts") + .and_then(Value::as_array) + .ok_or_else(|| { + AppError::InvalidInput("legacy manifest does not contain artifacts".to_owned()) + })?; + let gaps = legacy_gaps(&legacy)?; + if values.len().saturating_add(gaps.len()) > MAX_SCCM_MANIFEST_ARTIFACTS { + return Err(AppError::InvalidInput( + "legacy manifest has too many artifacts or gaps".to_owned(), + )); + } + let mut artifacts = values + .iter() + .enumerate() + .map(|(index, artifact)| legacy_artifact(index, artifact)) + .collect::, _>>()?; + artifacts.extend( + gaps.iter() + .enumerate() + .map(|(index, gap)| legacy_gap(index, gap)) + .collect::, _>>()?, + ); + Ok(SccmBundleManifestV1 { + sccm_manifest_version: SCCM_MANIFEST_VERSION, + diagnostics_schema_version: SCCM_DIAGNOSTICS_SCHEMA_VERSION, + source_catalog_version: 0, + provenance: SccmManifestProvenance::LegacyGenericUnscoped, + provenance_profile: SccmManifestProvenanceProfile::LegacyGenericUnscoped, + host_handle: None, + collected_at_utc: None, + max_files_per_source: 0, + max_bytes_per_source: 0, + artifacts, + capture_gaps: Vec::new(), + }) +} + +fn read_legacy_value(bundle_root: &VerifiedBundleRoot) -> Result { + let input = bundle_root + .open_relative_file(Path::new(LEGACY_MANIFEST_FILE_NAME)) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + AppError::InvalidInput(format!( + "bundle contains neither {SCCM_MANIFEST_FILE_NAME} nor {LEGACY_MANIFEST_FILE_NAME}" + )) + } else { + AppError::InvalidInput("legacy manifest cannot be opened safely".to_owned()) + } + })?; + let bytes = read_bounded_file(input, MAX_SCCM_MANIFEST_BYTES, "legacy manifest")?; + serde_json::from_slice(&bytes).map_err(|error| AppError::Parse { + file: LEGACY_MANIFEST_FILE_NAME.to_owned(), + reason: error.to_string(), + }) +} + +fn legacy_gaps(legacy: &Value) -> Result<&[Value], AppError> { + match legacy.pointer("/collection/results/gaps") { + Some(Value::Array(gaps)) => Ok(gaps), + Some(_) => Err(AppError::InvalidInput( + "legacy manifest collection gaps are malformed".to_owned(), + )), + None => Ok(&[]), + } +} + +fn read_legacy_client_intake_bundle( + bundle_root: &VerifiedBundleRoot, +) -> Result { + let legacy = read_legacy_value(bundle_root)?; + let supported_profile = legacy + .pointer("/collection/collectorProfile") + .and_then(Value::as_str) + == Some(LEGACY_GENERIC_PROFILE_NAME) + && legacy + .pointer("/collection/collectorVersion") + .and_then(Value::as_str) + == Some(LEGACY_GENERIC_PROFILE_VERSION); + if !supported_profile { + return Ok(SccmClientIntakeBundle { + artifacts: Vec::new(), + capture_gaps: Vec::new(), + }); + } + let gaps = legacy_gaps(&legacy)?; + if gaps.len() > MAX_SCCM_MANIFEST_ARTIFACTS { + return Err(AppError::InvalidInput( + "legacy manifest has too many collection gaps".to_owned(), + )); + } + + let mut artifacts = Vec::new(); + let mut known_gap_seen = false; + for gap in gaps { + if gap.get("artifactId").and_then(Value::as_str) != Some(LEGACY_CONFIGMGR_CCM_LOGS_ID) + || gap.get("category").and_then(Value::as_str) != Some(LEGACY_CONFIGMGR_LOG_CATEGORY) + { + continue; + } + let state = match gap.get("status").and_then(Value::as_str) { + Some("Missing") => SccmManifestSourceState::Absent, + Some("Failed") => SccmManifestSourceState::FailedUnknownDetail, + _ => continue, + }; + if known_gap_seen { + return Err(AppError::InvalidInput( + "legacy manifest duplicates a known SCCM collection gap".to_owned(), + )); + } + known_gap_seen = true; + let catalog_id = catalog_entry_id(LEGACY_CCMSETUP_BASENAME); + artifacts.push(SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: expected_marker_artifact_id( + &catalog_id, + state, + &SccmRotation::Current, + LEGACY_CCMSETUP_BASENAME, + None, + ), + display_name: LEGACY_CCMSETUP_BASENAME.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: None, + collected_at_utc: None, + rotation: SccmRotation::Current, + coverage: state.pure_coverage(), + encoding: None, + }, + path_fingerprint: None, + rotation_lineage: None, + relative_path: None, + fragment_complete: Some(false), + declared_byte_length: None, + content_sha256: None, + }); + } + let bundle = SccmClientIntakeBundle { + artifacts, + capture_gaps: Vec::new(), + }; + assess_client_intake(&bundle).map_err(|error| { + AppError::InvalidInput(format!( + "legacy manifest cannot be converted to the pure client intake contract: {error}" + )) + })?; + Ok(bundle) +} + +fn legacy_gap(index: usize, gap: &Value) -> Result { + let legacy_id = legacy_identity(gap, "gap")?; + let state = match gap.get("status").and_then(Value::as_str) { + Some("Missing") => SccmManifestSourceState::Absent, + _ => SccmManifestSourceState::FailedUnknownDetail, + }; + Ok(legacy_unscoped_artifact("gap", index, legacy_id, state)) +} + +fn legacy_artifact(index: usize, artifact: &Value) -> Result { + let legacy_id = legacy_identity(artifact, "artifact")?; + if let Some(path) = artifact.get("relativePath").and_then(Value::as_str) { + validate_legacy_relative_path(path)?; + } + let state = match artifact.get("status").and_then(Value::as_str) { + Some("missing") => SccmManifestSourceState::Absent, + // Legacy manifests provide no verifiable evidence binding, so even a + // collected status remains a conservative unknown-detail failure. + _ => SccmManifestSourceState::FailedUnknownDetail, + }; + Ok(legacy_unscoped_artifact( + "artifact", index, legacy_id, state, + )) +} + +fn legacy_identity<'a>(value: &'a Value, kind: &str) -> Result<&'a str, AppError> { + let identity = value + .get("artifactId") + .and_then(Value::as_str) + .ok_or_else(|| AppError::InvalidInput(format!("legacy {kind} is missing artifactId")))?; + if identity.is_empty() || identity.chars().count() > MAX_SAFE_TEXT_CHARS { + return Err(AppError::InvalidInput(format!( + "legacy {kind} identity is empty or too long" + ))); + } + Ok(identity) +} + +fn legacy_unscoped_artifact( + domain: &str, + index: usize, + legacy_id: &str, + state: SccmManifestSourceState, +) -> SccmManifestArtifact { + let digest = sha256_bytes(format!("legacy:v1:{domain}:{index}:{legacy_id}").as_bytes()); + SccmManifestArtifact { + catalog_entry_id: "legacy-generic-unscoped:v1".to_owned(), + logical_artifact_ids: Vec::new(), + artifact_id: format!("sccm-artifact:v1:sha256:{digest}"), + role: SccmRole::Unknown("legacyGenericUnscoped".to_owned()), + source_handle: None, + root_handle: None, + path_fingerprint: None, + rotation_lineage: None, + relative_path: None, + basename: format!("sccm-unknown-v1-sha256-{digest}.log"), + rotation: SccmRotation::Current, + state, + coverage_scope: SccmManifestCoverageScope::Source, + capture_limit_kind: None, + bytes_copied: 0, + limit_applied: None, + content_sha256: None, + fragment_complete: false, + configmgr_version: None, + collected_at_utc: None, + encoding: None, + } +} + +fn validate_legacy_relative_path(value: &str) -> Result<(), AppError> { + if value.is_empty() + || value.len() > 512 + || value.contains('\\') + || Path::new(value).is_absolute() + || !value.starts_with("evidence/") + || Path::new(value) + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(AppError::InvalidInput( + "legacy artifact relative path is unsafe".to_owned(), + )); + } + Ok(()) +} + +fn canonical_basename(value: &str) -> String { + cmtraceopen_parser::sccm::classify_artifact_name(value, SccmRole::Client) + .basename + .to_ascii_lowercase() +} + +fn is_utc_rfc3339(value: &str) -> bool { + value.len() <= 64 + && value.is_ascii() + && DateTime::parse_from_rfc3339(value) + .is_ok_and(|timestamp| timestamp.offset().local_minus_utc() == 0) +} + +fn is_supported_encoding(value: &str) -> bool { + matches!(value, "utf-8" | "utf-16le" | "utf-16be" | "windows-1252") +} + +fn is_safe_configmgr_version(value: &str) -> bool { + if matches!(value, "5.00.TEST.0000" | "5.00.UNKNOWN.0000") { + return true; + } + let mut components = value.split('.'); + matches!(components.next(), Some("5")) + && matches!(components.next(), Some("00")) + && components.next().is_some_and(is_four_ascii_digits) + && components.next().is_some_and(is_four_ascii_digits) + && components.next().is_none() +} + +fn is_four_ascii_digits(value: &str) -> bool { + value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn native_intake_reader_assesses_the_projection_once() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + fs::create_dir(&bundle_root).expect("create bundle root"); + fs::set_permissions(&bundle_root, fs::Permissions::from_mode(0o700)) + .expect("make bundle root private"); + let manifest = serde_json::json!({ + "sccmManifestVersion": 1, + "diagnosticsSchemaVersion": 1, + "sourceCatalogVersion": 1, + "provenance": "nativeClientCapture", + "provenanceProfile": "hmacSha256V1", + "collectedAtUtc": "2026-07-30T15:00:00Z", + "maxFilesPerSource": 8, + "maxBytesPerSource": 4096, + "artifacts": [], + "captureGaps": [] + }); + fs::write( + bundle_root.join(SCCM_MANIFEST_FILE_NAME), + serde_json::to_vec(&manifest).expect("serialize native manifest"), + ) + .expect("write native manifest"); + + reset_intake_projection_count(); + let bundle = read_sccm_client_intake_bundle(&bundle_root).expect("read native intake"); + + assert!(bundle.artifacts.is_empty()); + assert!(bundle.capture_gaps.is_empty()); + assert_eq!(intake_projection_count(), 1); + } + + #[test] + fn client_owned_physical_byte_budget_accepts_exact_ceilings_without_opening_evidence() { + let mut total = 0; + for _ in 0..4 { + add_to_client_physical_byte_budget(MAX_SCCM_CLIENT_PHYSICAL_ARTIFACT_BYTES, &mut total) + .expect("the exact reader-owned ceilings are admitted before any file is opened"); + } + assert_eq!(total, MAX_SCCM_CLIENT_TOTAL_PHYSICAL_BYTES); + + let error = add_to_client_physical_byte_budget(1, &mut total) + .expect_err("one byte beyond the aggregate ceiling is rejected before a file open"); + assert!(error.to_string().contains("aggregate physical byte cap")); + } + + #[test] + fn client_owned_physical_byte_budget_rejects_accumulator_overflow() { + let mut total = u64::MAX; + + let error = add_to_client_physical_byte_budget(1, &mut total) + .expect_err("metadata summation cannot wrap the aggregate byte counter"); + + assert!(error.to_string().contains("aggregate physical byte cap")); + assert_eq!( + total, + u64::MAX, + "a rejected addition leaves the counter intact" + ); + } +} diff --git a/src-tauri/src/sccm/mod.rs b/src-tauri/src/sccm/mod.rs new file mode 100644 index 000000000..96d0c23be --- /dev/null +++ b/src-tauri/src/sccm/mod.rs @@ -0,0 +1,16 @@ +//! Reader-only native SCCM diagnostic manifest boundary. +//! +//! The pure diagnostic models and reducers remain in `cmtraceopen-parser`. +//! This module validates native bundle provenance and projects it into those +//! pure contracts without changing the generic collection manifest. + +pub mod collector; +mod contract; +mod discovery; +mod manifest; +mod private_fs; + +pub use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole, SccmRotation}; +pub use contract::*; +pub use discovery::*; +pub use manifest::*; diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs new file mode 100644 index 000000000..eda140314 --- /dev/null +++ b/src-tauri/src/sccm/private_fs.rs @@ -0,0 +1,1075 @@ +use std::fs::{self, File, OpenOptions}; +use std::io; +use std::path::{Component, Path}; + +use crate::error::AppError; + +/// A bundle root whose identity remains bound for the lifetime of a native read. +/// +/// Unix keeps an open directory descriptor and never re-resolves descendants by +/// pathname. Windows keeps a non-followed directory handle and opens each +/// component relative to that handle. +pub(super) struct VerifiedBundleRoot { + #[cfg(unix)] + directory: File, + #[cfg(windows)] + directory: File, +} + +pub(super) fn verify_bundle_root(bundle_root: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(bundle_root) + .map_err(|_| { + AppError::InvalidInput("SCCM bundle root is unavailable or unsafe".to_owned()) + })?; + let metadata = directory.metadata().map_err(|_| { + AppError::InvalidInput("SCCM bundle root metadata cannot be verified".to_owned()) + })?; + if is_reparse_point(&metadata) || !metadata.is_dir() { + return Err(AppError::InvalidInput( + "SCCM bundle root must be a real directory".to_owned(), + )); + } + verify_private_directory(bundle_root, &metadata)?; + Ok(VerifiedBundleRoot { directory }) + } + + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + let directory = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(bundle_root) + .map_err(|_| { + AppError::InvalidInput("SCCM bundle root is unavailable or unsafe".to_owned()) + })?; + require_real_windows_directory(&directory).map_err(|_| { + AppError::InvalidInput("SCCM bundle root must be a real directory".to_owned()) + })?; + verify_private_directory(&directory)?; + Ok(VerifiedBundleRoot { directory }) + } + + #[cfg(not(any(unix, windows)))] + { + let _ = bundle_root; + Err(AppError::InvalidInput( + "native SCCM manifest reading requires handle-bound directory traversal on this platform" + .to_owned(), + )) + } +} + +impl VerifiedBundleRoot { + pub(super) fn open_relative_file(&self, relative: &Path) -> io::Result { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + + open_relative_file_no_follow(self.directory.as_raw_fd(), relative) + } + + #[cfg(windows)] + { + open_relative_file_no_follow(&self.directory, relative) + } + + #[cfg(not(any(unix, windows)))] + { + let _ = relative; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "handle-bound directory traversal is unavailable", + )) + } + } +} + +#[cfg(unix)] +fn verify_private_directory(_path: &Path, metadata: &fs::Metadata) -> Result<(), AppError> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + // SAFETY: `geteuid` has no preconditions and reads process identity only. + let effective_user = unsafe { libc::geteuid() }; + if metadata.uid() != 0 && metadata.uid() != effective_user { + return Err(AppError::InvalidInput( + "SCCM bundle directory is not owned by the capture user".to_owned(), + )); + } + if metadata.permissions().mode() & 0o077 != 0 { + return Err(AppError::InvalidInput( + "SCCM bundle directory is not private".to_owned(), + )); + } + Ok(()) +} + +#[cfg(windows)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WindowsAclTrustee { + Owner, + LocalSystem, + BuiltinAdministrators, + CreatorOwner, + Other, +} + +#[cfg(windows)] +fn windows_allow_ace_is_restricted(trustee: WindowsAclTrustee, inherit_only: bool) -> bool { + matches!( + trustee, + WindowsAclTrustee::Owner + | WindowsAclTrustee::LocalSystem + | WindowsAclTrustee::BuiltinAdministrators + ) || (trustee == WindowsAclTrustee::CreatorOwner && inherit_only) +} + +#[cfg(windows)] +fn verify_private_directory(directory: &File) -> Result<(), AppError> { + use std::os::windows::io::AsRawHandle; + + use windows::Win32::Foundation::{CloseHandle, LocalFree, ERROR_SUCCESS, HANDLE, HLOCAL}; + use windows::Win32::Security::Authorization::{ + GetExplicitEntriesFromAclW, GetSecurityInfo, GRANT_ACCESS, SET_ACCESS, SE_FILE_OBJECT, + TRUSTEE_IS_SID, + }; + use windows::Win32::Security::{ + EqualSid, GetTokenInformation, IsValidSid, IsWellKnownSid, TokenUser, + WinBuiltinAdministratorsSid, WinCreatorOwnerSid, WinLocalSystemSid, ACL, + DACL_SECURITY_INFORMATION, INHERIT_ONLY_ACE, OWNER_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TOKEN_USER, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + const MAX_ACL_ENTRIES: u32 = 4096; + + struct LocalAllocation(*mut core::ffi::c_void); + + impl Drop for LocalAllocation { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + let _ = LocalFree(Some(HLOCAL(self.0))); + } + } + } + } + + struct OwnedHandle(HANDLE); + + impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_invalid() { + unsafe { + let _ = CloseHandle(self.0); + } + } + } + } + + fn owner_is_current_process_user(owner: PSID) -> Result { + let mut token = HANDLE::default(); + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.map_err( + |_| { + AppError::InvalidInput( + "SCCM bundle root ACL owner could not be verified".to_owned(), + ) + }, + )?; + let _token = OwnedHandle(token); + let mut required = 0_u32; + let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut required) }; + if required < std::mem::size_of::() as u32 { + return Err(AppError::InvalidInput( + "SCCM bundle root ACL owner token information is unavailable".to_owned(), + )); + } + let word_bytes = std::mem::size_of::(); + let word_count = (required as usize).div_ceil(word_bytes); + let mut buffer = vec![0_usize; word_count]; + let mut returned = required; + unsafe { + GetTokenInformation( + token, + TokenUser, + Some(buffer.as_mut_ptr().cast()), + required, + &mut returned, + ) + } + .map_err(|_| { + AppError::InvalidInput( + "SCCM bundle root ACL owner token information could not be read".to_owned(), + ) + })?; + if returned > required { + return Err(AppError::InvalidInput( + "SCCM bundle root ACL owner token information changed during validation".to_owned(), + )); + } + let token_user = unsafe { &*buffer.as_ptr().cast::() }; + let token_sid = token_user.User.Sid; + if token_sid.is_invalid() || !unsafe { IsValidSid(token_sid).as_bool() } { + return Err(AppError::InvalidInput( + "SCCM bundle root ACL owner token contains an invalid SID".to_owned(), + )); + } + Ok(unsafe { EqualSid(owner, token_sid).is_ok() }) + } + + let mut owner = PSID::default(); + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + let status = unsafe { + GetSecurityInfo( + HANDLE(directory.as_raw_handle()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + Some(&mut owner), + None, + Some(&mut dacl), + None, + Some(&mut descriptor), + ) + }; + let _descriptor = LocalAllocation(descriptor.0); + if status != ERROR_SUCCESS { + return Err(AppError::InvalidInput(format!( + "SCCM bundle root ACL could not be verified (Win32 error {})", + status.0 + ))); + } + if owner.is_invalid() || !unsafe { IsValidSid(owner).as_bool() } || dacl.is_null() { + return Err(AppError::InvalidInput( + "SCCM bundle root ACL has no valid owner or has a null DACL".to_owned(), + )); + } + let owner_is_current_user = owner_is_current_process_user(owner)?; + let owner_is_local_system = unsafe { IsWellKnownSid(owner, WinLocalSystemSid).as_bool() }; + let owner_is_builtin_administrator = + unsafe { IsWellKnownSid(owner, WinBuiltinAdministratorsSid).as_bool() }; + if !(owner_is_current_user || owner_is_local_system || owner_is_builtin_administrator) { + return Err(AppError::InvalidInput( + "SCCM bundle root ACL owner is not the capture user, LocalSystem, or Administrators" + .to_owned(), + )); + } + + let mut entry_count = 0_u32; + let mut entries = std::ptr::null_mut(); + let status = unsafe { GetExplicitEntriesFromAclW(dacl, &mut entry_count, &mut entries) }; + let _entries = LocalAllocation(entries.cast()); + if status != ERROR_SUCCESS { + return Err(AppError::InvalidInput(format!( + "SCCM bundle root ACL entries could not be verified (Win32 error {})", + status.0 + ))); + } + if entry_count > MAX_ACL_ENTRIES || (entry_count != 0 && entries.is_null()) { + return Err(AppError::InvalidInput( + "SCCM bundle root ACL has an unsafe entry count".to_owned(), + )); + } + let entries = if entry_count == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(entries, entry_count as usize) } + }; + for entry in entries { + if entry.grfAccessPermissions == 0 + || !matches!(entry.grfAccessMode, GRANT_ACCESS | SET_ACCESS) + { + continue; + } + if entry.Trustee.TrusteeForm != TRUSTEE_IS_SID || entry.Trustee.ptstrName.is_null() { + return Err(AppError::InvalidInput( + "SCCM bundle root ACL contains an unverifiable allow trustee".to_owned(), + )); + } + let sid = PSID(entry.Trustee.ptstrName.0.cast()); + if !unsafe { IsValidSid(sid).as_bool() } { + return Err(AppError::InvalidInput( + "SCCM bundle root ACL contains an invalid allow trustee".to_owned(), + )); + } + let trustee = if unsafe { EqualSid(sid, owner).is_ok() } { + WindowsAclTrustee::Owner + } else if unsafe { IsWellKnownSid(sid, WinLocalSystemSid).as_bool() } { + WindowsAclTrustee::LocalSystem + } else if unsafe { IsWellKnownSid(sid, WinBuiltinAdministratorsSid).as_bool() } { + WindowsAclTrustee::BuiltinAdministrators + } else if unsafe { IsWellKnownSid(sid, WinCreatorOwnerSid).as_bool() } { + WindowsAclTrustee::CreatorOwner + } else { + WindowsAclTrustee::Other + }; + let inherit_only = entry.grfInheritance.0 & INHERIT_ONLY_ACE.0 != 0; + if !windows_allow_ace_is_restricted(trustee, inherit_only) { + return Err(AppError::InvalidInput( + "SCCM bundle root ACL grants access to a non-privileged trustee".to_owned(), + )); + } + } + Ok(()) +} + +#[cfg(all(unix, test))] +pub(super) fn open_file_no_follow(path: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path)?; + let file = require_regular_file(file)?; + clear_nonblock(&file)?; + Ok(file) +} + +#[cfg(unix)] +fn clear_nonblock(file: &File) -> io::Result<()> { + use std::os::fd::AsRawFd; + + let descriptor = file.as_raw_fd(); + // SAFETY: `descriptor` is borrowed from the live `File`; both fcntl calls + // operate only on its status flags and preserve every flag except NONBLOCK. + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFL) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { libc::fcntl(descriptor, libc::F_SETFL, flags & !libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(unix)] +fn open_relative_file_no_follow(root_fd: std::os::fd::RawFd, relative: &Path) -> io::Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let components = relative.components().collect::>(); + if components.is_empty() + || components + .iter() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle relative path is unsafe", + )); + } + + // Duplicate the root so every descriptor remains owned locally while each + // `openat` call is bound to the directory identity opened above. + let duplicate = unsafe { libc::fcntl(root_fd, libc::F_DUPFD_CLOEXEC, 0) }; + if duplicate < 0 { + return Err(io::Error::last_os_error()); + } + let mut directory = unsafe { File::from_raw_fd(duplicate) }; + for (index, component) in components.iter().enumerate() { + let Component::Normal(name) = component else { + unreachable!("unsafe components were rejected above"); + }; + let name = CString::new(name.as_bytes()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle path contains an interior NUL", + ) + })?; + let final_component = index + 1 == components.len(); + let flags = if final_component { + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC + } else { + libc::O_RDONLY + | libc::O_DIRECTORY + | libc::O_NOFOLLOW + | libc::O_NONBLOCK + | libc::O_CLOEXEC + }; + let descriptor = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + let opened = unsafe { File::from_raw_fd(descriptor) }; + if final_component { + let opened = require_regular_file(opened)?; + clear_nonblock(&opened)?; + return Ok(opened); + } + let metadata = opened.metadata()?; + if is_reparse_point(&metadata) || !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle ancestor is not a real directory", + )); + } + #[cfg(test)] + invoke_open_component_hook(name.as_c_str()); + directory = opened; + } + unreachable!("non-empty relative paths always return from their final component") +} + +#[cfg(windows)] +fn open_relative_file_no_follow(root: &File, relative: &Path) -> io::Result { + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; + use windows::core::PWSTR; + use windows::Wdk::Foundation::OBJECT_ATTRIBUTES; + use windows::Wdk::Storage::FileSystem::{ + NtCreateFile, FILE_DIRECTORY_FILE, FILE_NON_DIRECTORY_FILE, FILE_OPEN, + FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, + }; + use windows::Win32::Foundation::{ + RtlNtStatusToDosError, HANDLE, OBJ_CASE_INSENSITIVE, UNICODE_STRING, + }; + use windows::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + use windows::Win32::System::IO::IO_STATUS_BLOCK; + + let components = relative.components().collect::>(); + if components.is_empty() + || components + .iter() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle relative path is unsafe", + )); + } + + let mut held_directories = Vec::new(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(name) = component else { + unreachable!("unsafe components were rejected above"); + }; + let mut wide_name = name.encode_wide().collect::>(); + let wide_bytes = wide_name + .len() + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle path component is too long", + ) + })?; + if wide_name.is_empty() + || wide_name.contains(&0) + || wide_name.contains(&(b':' as u16)) + || wide_bytes > u16::MAX as usize + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle path contains an invalid component", + )); + } + let mut object_name = UNICODE_STRING { + Length: wide_bytes as u16, + MaximumLength: wide_bytes as u16, + Buffer: PWSTR(wide_name.as_mut_ptr()), + }; + let object_attributes = OBJECT_ATTRIBUTES { + Length: std::mem::size_of::() as u32, + RootDirectory: held_directories.last().map_or_else( + || HANDLE(root.as_raw_handle()), + |directory: &File| HANDLE(directory.as_raw_handle()), + ), + ObjectName: &mut object_name, + Attributes: OBJ_CASE_INSENSITIVE, + SecurityDescriptor: std::ptr::null(), + SecurityQualityOfService: std::ptr::null(), + }; + let final_component = index + 1 == components.len(); + let options = if final_component { + FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT + } else { + FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT + }; + let mut handle = HANDLE::default(); + let mut io_status = IO_STATUS_BLOCK::default(); + let status = unsafe { + NtCreateFile( + &mut handle, + FILE_GENERIC_READ, + &object_attributes, + &mut io_status, + None, + FILE_ATTRIBUTE_NORMAL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + FILE_OPEN, + options, + None, + 0, + ) + }; + if status.0 < 0 || handle.0.is_null() || handle.is_invalid() { + if status.0 < 0 { + // SAFETY: RtlNtStatusToDosError converts the NTSTATUS returned + // by NtCreateFile without dereferencing caller-owned memory. + return Err(io::Error::from_raw_os_error(unsafe { + RtlNtStatusToDosError(status) as i32 + })); + } + return Err(io::Error::other( + "SCCM bundle entry could not be opened safely", + )); + } + // SAFETY: a successful NtCreateFile returns an owned handle. This File + // owns it until it is returned or replaced by the next live ancestor. + let opened = unsafe { File::from_raw_handle(handle.0) }; + if final_component { + return require_regular_file(opened); + } + require_real_windows_directory(&opened)?; + held_directories.push(opened); + #[cfg(test)] + invoke_open_component_hook(name); + } + unreachable!("non-empty relative paths always return from their final component") +} + +fn require_regular_file(file: File) -> io::Result { + #[cfg(windows)] + { + require_real_windows_file(&file)?; + Ok(file) + } + + #[cfg(not(windows))] + { + let metadata = file.metadata()?; + if is_reparse_point(&metadata) || !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle entry is not a regular file", + )); + } + require_single_link(&file)?; + Ok(file) + } +} + +#[cfg(unix)] +fn require_single_link(file: &File) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + + if file.metadata()?.nlink() != 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle entry must be a single-link file", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn windows_file_information( + file: &File, +) -> io::Result { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + unsafe { + GetFileInformationByHandle( + windows::Win32::Foundation::HANDLE(file.as_raw_handle()), + &mut information, + ) + } + .map_err(|error| io::Error::other(error.to_string()))?; + Ok(information) +} + +#[cfg(windows)] +fn require_real_windows_directory(file: &File) -> io::Result<()> { + use windows::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, + }; + + let information = windows_file_information(file)?; + let attributes = information.dwFileAttributes; + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 + || attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle ancestor is not a real directory", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn require_real_windows_file(file: &File) -> io::Result<()> { + use windows::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, + }; + + let information = windows_file_information(file)?; + if information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 + || information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle entry is not a regular file", + )); + } + if information.nNumberOfLinks != 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCCM bundle entry must be a single-link file", + )); + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn require_single_link(_file: &File) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "SCCM bundle link count cannot be verified", + )) +} + +pub(super) fn is_reparse_point(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true; + } + } + false +} + +#[cfg(all(test, unix))] +type OpenComponentHook = Box; + +#[cfg(all(test, unix))] +thread_local! { + static OPEN_COMPONENT_HOOK: std::cell::RefCell> = + std::cell::RefCell::new(None); +} + +#[cfg(all(test, unix))] +fn set_open_component_hook(hook: Option) { + OPEN_COMPONENT_HOOK.with(|slot| *slot.borrow_mut() = hook); +} + +#[cfg(all(test, any(unix, windows)))] +struct OpenComponentHookGuard; + +#[cfg(all(test, any(unix, windows)))] +impl OpenComponentHookGuard { + fn install(hook: OpenComponentHook) -> Self { + set_open_component_hook(Some(hook)); + Self + } +} + +#[cfg(all(test, any(unix, windows)))] +impl Drop for OpenComponentHookGuard { + fn drop(&mut self) { + set_open_component_hook(None); + } +} + +#[cfg(all(test, unix))] +fn invoke_open_component_hook(component: &std::ffi::CStr) { + OPEN_COMPONENT_HOOK.with(|slot| { + if let Some(hook) = slot.borrow_mut().as_mut() { + hook(component); + } + }); +} + +#[cfg(all(test, windows))] +type OpenComponentHook = Box; + +#[cfg(all(test, windows))] +thread_local! { + static OPEN_COMPONENT_HOOK: std::cell::RefCell> = + std::cell::RefCell::new(None); +} + +#[cfg(all(test, windows))] +fn set_open_component_hook(hook: Option) { + OPEN_COMPONENT_HOOK.with(|slot| *slot.borrow_mut() = hook); +} + +#[cfg(all(test, windows))] +fn invoke_open_component_hook(component: &std::ffi::OsStr) { + OPEN_COMPONENT_HOOK.with(|slot| { + if let Some(hook) = slot.borrow_mut().as_mut() { + hook(component); + } + }); +} + +#[cfg(all(test, unix))] +mod tests { + use std::cell::RefCell; + use std::io::Read; + use std::os::fd::AsRawFd; + use std::rc::Rc; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn safe_open_returns_only_regular_blocking_files() { + let root = tempdir().expect("temporary root"); + let path = root.path().join("manifest.json"); + fs::write(&path, b"{}").expect("synthetic manifest"); + + let file = open_file_no_follow(&path).expect("regular file"); + let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; + assert!(flags >= 0, "opened descriptor flags are readable"); + assert_eq!(flags & libc::O_NONBLOCK, 0); + + open_file_no_follow(root.path()).expect_err("directories are rejected after opening"); + } + + #[test] + fn handle_relative_open_returns_a_blocking_final_descriptor() { + let root = tempdir().expect("temporary root"); + let bundle = root.path().join("bundle"); + fs::create_dir_all(bundle.join("evidence")).expect("private bundle"); + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&bundle, fs::Permissions::from_mode(0o700)).expect("private root"); + fs::write(bundle.join("evidence/manifest.json"), b"{}").expect("synthetic manifest"); + + let verified = verify_bundle_root(&bundle).expect("verified root"); + let file = verified + .open_relative_file(Path::new("evidence/manifest.json")) + .expect("regular nested file"); + let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; + assert!(flags >= 0, "opened descriptor flags are readable"); + assert_eq!(flags & libc::O_NONBLOCK, 0); + } + + #[test] + fn open_component_hook_guard_clears_the_hook_after_unwinding() { + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _hook = OpenComponentHookGuard::install(Box::new(|_| { + panic!("stale hook must not survive this scope") + })); + panic!("test unwind"); + })); + assert!(unwind.is_err()); + + let component = std::ffi::CString::new("evidence").expect("test component"); + invoke_open_component_hook(component.as_c_str()); + } + + #[test] + fn verified_root_keeps_reading_the_original_directory_after_root_replacement() { + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + let replacement = temp.path().join("replacement"); + fs::create_dir_all(root.join("nested")).expect("create original bundle"); + fs::create_dir_all(replacement.join("nested")).expect("create replacement bundle"); + fs::write(root.join("nested/evidence.log"), b"original").expect("original evidence"); + fs::write(replacement.join("nested/evidence.log"), b"replacement") + .expect("replacement evidence"); + for directory in [&root, &replacement] { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)) + .expect("private root"); + } + + let verified = verify_bundle_root(&root).expect("open original root"); + fs::rename(&root, temp.path().join("retired")).expect("move original root"); + fs::rename(&replacement, &root).expect("install replacement root"); + + let mut opened = verified + .open_relative_file(Path::new("nested/evidence.log")) + .expect("bound root remains readable"); + let mut contents = String::new(); + opened + .read_to_string(&mut contents) + .expect("read bound evidence"); + assert_eq!(contents, "original"); + } + + #[test] + fn verified_root_keeps_an_opened_ancestor_after_a_deterministic_swap() { + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + let replacement = temp.path().join("replacement-evidence"); + fs::create_dir_all(root.join("evidence/nested")).expect("create original evidence"); + fs::create_dir_all(replacement.join("nested")).expect("create replacement evidence"); + fs::write(root.join("evidence/nested/evidence.log"), b"original") + .expect("original evidence"); + fs::write(replacement.join("nested/evidence.log"), b"replacement") + .expect("replacement evidence"); + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).expect("private root"); + + let verified = verify_bundle_root(&root).expect("open original root"); + let retired = temp.path().join("retired-evidence"); + let fired = Rc::new(RefCell::new(false)); + let fired_in_hook = Rc::clone(&fired); + let _hook = OpenComponentHookGuard::install(Box::new(move |component| { + if component.to_bytes() == b"evidence" && !*fired_in_hook.borrow() { + *fired_in_hook.borrow_mut() = true; + fs::rename(root.join("evidence"), &retired).expect("retire opened ancestor"); + fs::rename(&replacement, root.join("evidence")) + .expect("install replacement ancestor"); + } + })); + + let mut opened = verified + .open_relative_file(Path::new("evidence/nested/evidence.log")) + .expect("opened ancestor remains bound"); + let mut contents = String::new(); + opened + .read_to_string(&mut contents) + .expect("read bound evidence"); + assert!(*fired.borrow(), "test hook ran after ancestor open"); + assert_eq!(contents, "original"); + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use std::cell::RefCell; + use std::io::Read; + use std::rc::Rc; + + use tempfile::tempdir; + + use super::*; + + fn make_private_directory(path: &Path) { + use std::os::windows::{fs::OpenOptionsExt, io::AsRawHandle}; + + use windows::core::PWSTR; + use windows::Win32::Foundation::{LocalFree, ERROR_SUCCESS, HANDLE, HLOCAL}; + use windows::Win32::Security::Authorization::{ + GetSecurityInfo, SetEntriesInAclW, SetSecurityInfo, EXPLICIT_ACCESS_W, GRANT_ACCESS, + NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + }; + use windows::Win32::Security::{ + ACL, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + }; + use windows::Win32::Storage::FileSystem::{ + FILE_ALL_ACCESS, FILE_FLAG_BACKUP_SEMANTICS, READ_CONTROL, WRITE_DAC, + }; + + fs::create_dir_all(path).expect("create bundle directory"); + // READ_CONTROL is required for GetSecurityInfo (owner query). + // WRITE_DAC is required for SetSecurityInfo (DACL write); it is NOT included + // in GENERIC_READ, so using .read(true) alone produces ERROR_ACCESS_DENIED. + let directory = OpenOptions::new() + .access_mode(READ_CONTROL.0 | WRITE_DAC.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(path) + .expect("open bundle directory for DACL fixture"); + let mut owner = PSID::default(); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + let status = unsafe { + GetSecurityInfo( + HANDLE(directory.as_raw_handle()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + Some(&mut owner), + None, + None, + None, + Some(&mut descriptor), + ) + }; + assert_eq!(status, ERROR_SUCCESS, "read fixture owner"); + let fixture_access = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_ALL_ACCESS.0, + grfAccessMode: GRANT_ACCESS, + grfInheritance: windows::Win32::Security::SUB_CONTAINERS_AND_OBJECTS_INHERIT, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: PWSTR(owner.0.cast()), + }, + }; + let mut dacl: *mut ACL = std::ptr::null_mut(); + let status = unsafe { SetEntriesInAclW(Some(&[fixture_access]), None, &mut dacl) }; + assert_eq!(status, ERROR_SUCCESS, "build restrictive fixture DACL"); + let status = unsafe { + SetSecurityInfo( + HANDLE(directory.as_raw_handle()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + None, + None, + Some(dacl), + None, + ) + }; + unsafe { + let _ = LocalFree(Some(HLOCAL(descriptor.0))); + let _ = LocalFree(Some(HLOCAL(dacl.cast()))); + } + assert_eq!(status, ERROR_SUCCESS, "install restrictive fixture DACL"); + } + + #[test] + fn missing_final_component_preserves_not_found_for_legacy_fallback() { + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + make_private_directory(&root); + + let verified = verify_bundle_root(&root).expect("open private root"); + let error = verified + .open_relative_file(Path::new("sccm-manifest.json")) + .expect_err("missing native manifest is reported to the legacy fallback"); + + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn relative_component_rejects_alternate_data_streams() { + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + make_private_directory(&root); + fs::write(root.join("manifest.json"), b"{}\n").expect("create manifest"); + + let verified = verify_bundle_root(&root).expect("open private root"); + let error = verified + .open_relative_file(Path::new("manifest.json:alternate")) + .expect_err("alternate data streams cannot be opened as bundle entries"); + + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn verified_root_keeps_reading_the_original_directory_after_root_replacement() { + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + let replacement = temp.path().join("replacement"); + make_private_directory(&root); + make_private_directory(&replacement); + fs::create_dir_all(root.join("nested")).expect("create original bundle"); + fs::create_dir_all(replacement.join("nested")).expect("create replacement bundle"); + fs::write(root.join("nested/evidence.log"), b"original").expect("original evidence"); + fs::write(replacement.join("nested/evidence.log"), b"replacement") + .expect("replacement evidence"); + + let verified = verify_bundle_root(&root).expect("open original root"); + fs::rename(&root, temp.path().join("retired")).expect("move original root"); + fs::rename(&replacement, &root).expect("install replacement root"); + + let mut opened = verified + .open_relative_file(Path::new("nested/evidence.log")) + .expect("bound root remains readable"); + let mut contents = String::new(); + opened + .read_to_string(&mut contents) + .expect("read bound evidence"); + assert_eq!(contents, "original"); + } + + #[test] + fn verified_root_keeps_an_opened_ancestor_after_a_deterministic_swap() { + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + let replacement = temp.path().join("replacement-evidence"); + make_private_directory(&root); + make_private_directory(&replacement); + fs::create_dir_all(root.join("evidence/nested")).expect("create original evidence"); + fs::create_dir_all(replacement.join("nested")).expect("create replacement evidence"); + fs::write(root.join("evidence/nested/evidence.log"), b"original") + .expect("original evidence"); + fs::write(replacement.join("nested/evidence.log"), b"replacement") + .expect("replacement evidence"); + + let verified = verify_bundle_root(&root).expect("open original root"); + let retired = temp.path().join("retired-evidence"); + let fired = Rc::new(RefCell::new(false)); + let fired_in_hook = Rc::clone(&fired); + let _hook = OpenComponentHookGuard::install(Box::new(move |component| { + if component.eq_ignore_ascii_case("evidence") && !*fired_in_hook.borrow() { + *fired_in_hook.borrow_mut() = true; + fs::rename(root.join("evidence"), &retired).expect("retire opened ancestor"); + fs::rename(&replacement, root.join("evidence")) + .expect("install replacement ancestor"); + } + })); + + let mut opened = verified + .open_relative_file(Path::new("evidence/nested/evidence.log")) + .expect("opened ancestor remains bound"); + let mut contents = String::new(); + opened + .read_to_string(&mut contents) + .expect("read bound evidence"); + assert!(*fired.borrow(), "test hook ran after ancestor open"); + assert_eq!(contents, "original"); + } + + #[test] + fn verified_root_rejects_a_hard_linked_final_entry() { + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + make_private_directory(&root); + let manifest = root.join("manifest.json"); + let second_link = root.join("manifest-copy.json"); + fs::write(&manifest, b"{}\n").expect("manifest"); + fs::hard_link(&manifest, &second_link).expect("create hard link"); + + let verified = verify_bundle_root(&root).expect("open private root"); + let error = verified + .open_relative_file(Path::new("manifest.json")) + .expect_err("hard-linked entries are unsafe"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn verified_root_rejects_a_reparse_final_entry_when_symlinks_are_available() { + use std::os::windows::fs::symlink_file; + + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + make_private_directory(&root); + let target = temp.path().join("outside-manifest.json"); + fs::write(&target, b"outside").expect("outside manifest"); + if symlink_file(&target, root.join("manifest.json")).is_err() { + // Windows systems without Developer Mode or SeCreateSymbolicLinkPrivilege + // cannot create this fixture. The hosted Windows job covers the real path. + return; + } + + let verified = verify_bundle_root(&root).expect("open private root"); + let error = verified + .open_relative_file(Path::new("manifest.json")) + .expect_err("reparse entries are unsafe"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } +} diff --git a/src-tauri/src/sysmon/evtx_parser.rs b/src-tauri/src/sysmon/evtx_parser.rs index 68b18b912..dc74b4a96 100644 --- a/src-tauri/src/sysmon/evtx_parser.rs +++ b/src-tauri/src/sysmon/evtx_parser.rs @@ -274,10 +274,7 @@ pub fn build_summary( // for this event. String-only events can still update earliest/latest // even when other events had numeric timestamps. let ts = event.timestamp.as_str(); - if earliest_ts - .as_deref() - .is_none_or(|existing| ts < existing) - { + if earliest_ts.as_deref().is_none_or(|existing| ts < existing) { earliest_ts = Some(event.timestamp.clone()); } if latest_ts.as_deref().is_none_or(|existing| ts > existing) { diff --git a/src-tauri/tests/esp_diagnostics_sources.rs b/src-tauri/tests/esp_diagnostics_sources.rs index fcb6e10af..54aff2fca 100644 --- a/src-tauri/tests/esp_diagnostics_sources.rs +++ b/src-tauri/tests/esp_diagnostics_sources.rs @@ -62,11 +62,10 @@ use cmtraceopen_parser::esp::{ EspArtifactCoverage, EspArtifactStatus, EspDiagnosticsReducer, EspDiagnosticsSnapshot, EspElevationState, EspEvidenceProvenance, EspEvidenceRecord, EspEvidenceRef, EspGraphObservation, EspGraphObservationSection, EspHardwareEvidence, EspImeObservation, - EspObservationContext, - EspObservationValue, EspParseState, EspProcessObservation, EspRegistryObservation, - EspRegistryProvenance, EspScope, EspSensitivity, EspSourceAccessState, EspSourceKind, - EspSystemFact, EspSystemObservation, EspTimestamp, EspTimestampKind, GraphApiVersion, - MAX_EVIDENCE_IDENTITY_SOURCES, MAX_RETAINED_EVIDENCE_RECORDS, + EspObservationContext, EspObservationValue, EspParseState, EspProcessObservation, + EspRegistryObservation, EspRegistryProvenance, EspScope, EspSensitivity, EspSourceAccessState, + EspSourceKind, EspSystemFact, EspSystemObservation, EspTimestamp, EspTimestampKind, + GraphApiVersion, MAX_EVIDENCE_IDENTITY_SOURCES, MAX_RETAINED_EVIDENCE_RECORDS, }; use tempfile::tempdir; @@ -7884,9 +7883,11 @@ fn bundle_legacy_fallback_is_depth_extension_and_basename_allowlisted() { Some("Legacy Profile") ); assert!(snapshot.raw_evidence.iter().all(|record| { - record.provenance.file_path.as_deref().is_none_or(|path| { - !path.ends_with("arbitrary.json") && !path.ends_with("ignored.exe") - }) + record + .provenance + .file_path + .as_deref() + .is_none_or(|path| !path.ends_with("arbitrary.json") && !path.ends_with("ignored.exe")) })); } diff --git a/src-tauri/tests/sccm_client_discovery.rs b/src-tauri/tests/sccm_client_discovery.rs new file mode 100644 index 000000000..5f9c78b8b --- /dev/null +++ b/src-tauri/tests/sccm_client_discovery.rs @@ -0,0 +1,1399 @@ +use app_lib::sccm::{ + discover_client_sources, SccmClientDiscoveryCoverageIssueState, SccmClientDiscoveryError, + SccmClientDiscoveryInput, SccmClientDiscoveryObservation, SccmClientDiscoveryObservationState, + SccmClientDiscoveryRotationCategory, SccmClientDiscoveryState, + MAX_SCCM_CLIENT_DISCOVERY_COVERAGE_ISSUES, MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS, +}; +use cmtraceopen_parser::sccm::{SccmRotation, SccmUnknownRotation}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const ROOT_A: &str = "root-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ROOT_B: &str = "root-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn observation( + root_handle: &str, + basename: &str, + rotation: SccmRotation, + state: SccmClientDiscoveryObservationState, +) -> SccmClientDiscoveryObservation { + SccmClientDiscoveryObservation { + root_handle: root_handle.to_owned(), + basename: basename.to_owned(), + rotation, + state, + } +} + +fn unsupported_rotation(suffix: &str) -> SccmRotation { + SccmRotation::Unknown(SccmUnknownRotation { + kind: "filenameSuffix".to_owned(), + value: Some(Value::String(suffix.to_owned())), + }) +} + +fn sha256(value: impl AsRef<[u8]>) -> String { + Sha256::digest(value) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn path_fingerprint(root_handle: &str, canonical_basename: &str) -> String { + let root_digest = root_handle + .strip_prefix("root-") + .expect("synthetic root handle has the required prefix"); + format!( + "sha256:{}", + sha256(format!( + "cmtraceopen.sccm.source.v1\0{root_digest}\0{canonical_basename}" + )) + ) +} + +fn rotation_segment(rotation: &SccmRotation) -> String { + match rotation { + SccmRotation::Current => "current".to_owned(), + SccmRotation::LoUnderscore => "lo".to_owned(), + SccmRotation::Numbered(number) => format!("numbered-{number}"), + SccmRotation::Timestamped(timestamp) => format!("timestamped-{timestamp}"), + SccmRotation::Unknown(_) => panic!("synthetic observations use known rotations"), + } +} + +fn expected_physical_artifact_id( + fingerprint: &str, + rotation: &SccmRotation, + basename: &str, +) -> String { + format!( + "sccm-artifact:v1:sha256:{}", + sha256(format!( + "artifact:v1:{fingerprint}:{}:{basename}", + rotation_segment(rotation) + )) + ) +} + +fn expected_marker_artifact_id( + canonical_basename: &str, + state: &str, + fingerprint: &str, + rotation: &SccmRotation, + basename: &str, +) -> String { + let catalog_entry_id = format!( + "sccm-client-source:v1:sha256:{}", + sha256(canonical_basename) + ); + format!( + "sccm-artifact:v1:sha256:{}", + sha256(format!( + "marker:v1:{catalog_entry_id}:{state}:{}:{basename}:{fingerprint}", + rotation_segment(rotation) + )) + ) +} + +fn expected_catalog_entry_id(canonical_basename: &str) -> String { + format!( + "sccm-client-source:v1:sha256:{}", + sha256(canonical_basename) + ) +} + +fn expected_evidence_identity( + canonical_basename: &str, + root_handle: &str, + rotation: &SccmRotation, + physical_basename: &str, +) -> String { + let catalog_entry_id = expected_catalog_entry_id(canonical_basename); + let fingerprint = path_fingerprint(root_handle, canonical_basename); + let source_digest = fingerprint + .strip_prefix("sha256:") + .expect("path fingerprint has the expected versioned prefix"); + format!( + "sccm-evidence:v1:sha256:{}", + sha256(format!( + "cmtraceopen.sccm.evidence.v1\0{catalog_entry_id}\0{source_digest}\0{}\0{physical_basename}", + rotation_segment(rotation) + )) + ) +} + +#[test] +fn discovery_coverage_issue_ids_with_omitted_state_use_nul_domain_separators() { + let capacity = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 1, + observations: (0..=MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS) + .map(|number| { + observation( + &format!("root-{number:064x}"), + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ) + }) + .collect(), + }) + .expect("the global frontier emits a coverage issue"); + let capacity_issue = capacity + .coverage_issues + .iter() + .find(|issue| { + issue.state == SccmClientDiscoveryCoverageIssueState::DeclarationLimitExceeded + }) + .expect("the omitted declaration has a capacity issue"); + assert_eq!( + capacity_issue.artifact_id, + format!( + "sccm-discovery-coverage:v1:sha256:{}", + sha256(concat!( + "cmtraceopen.sccm.discovery.coverage.v1\0", + "sccm-client-source:v1:none\0", + "unknown\0declaration-limit-exceeded\0discovered", + )) + ), + "coverage IDs with an omitted state must hash true NUL-separated fields" + ); +} + +#[test] +fn discovery_coverage_issue_ids_without_omitted_state_use_nul_domain_separators() { + let invalid_provenance = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 1, + observations: vec![observation( + "not-a-root-handle", + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + )], + }) + .expect("invalid provenance remains explicit coverage"); + let invalid_provenance_issue = invalid_provenance + .coverage_issues + .iter() + .find(|issue| issue.state == SccmClientDiscoveryCoverageIssueState::InvalidProvenance) + .expect("invalid provenance has a coverage issue"); + let catalog_entry_id = expected_catalog_entry_id("AppEnforce.log"); + let expected_payload = format!( + "cmtraceopen.sccm.discovery.coverage.v1\0{catalog_entry_id}\0unknown\0invalid-provenance" + ); + assert_eq!( + invalid_provenance_issue.artifact_id, + format!( + "sccm-discovery-coverage:v1:sha256:{}", + sha256(expected_payload) + ), + "coverage IDs without an omitted state must hash true NUL-separated fields" + ); +} + +#[test] +fn discovery_uses_one_global_declaration_budget_and_reports_capacity_coverage() { + let mut observations = Vec::new(); + for number in 1..=2_048 { + observations.push(observation( + ROOT_A, + &format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number), + SccmClientDiscoveryObservationState::Found, + )); + observations.push(observation( + ROOT_B, + &format!("PolicyAgent.log.{number}"), + SccmRotation::Numbered(number), + SccmClientDiscoveryObservationState::Found, + )); + } + observations.push(observation( + ROOT_B, + "PolicyAgent.log.2049", + SccmRotation::Numbered(2_049), + SccmClientDiscoveryObservationState::Found, + )); + + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }) + .expect("valid observations"); + + assert_eq!( + result.declarations.len(), + MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + "the 4096 declaration budget must be shared by all roots and sources" + ); + let terminal = result + .declarations + .last() + .expect("the globally bounded result retains its deterministic terminal observation"); + assert_eq!(terminal.basename, "PolicyAgent.log.2049"); + assert_eq!(terminal.rotation, SccmRotation::Numbered(2_049)); + assert_eq!(terminal.state, SccmClientDiscoveryState::Discovered); + assert_eq!( + result + .declarations + .iter() + .filter(|declaration| declaration.state == SccmClientDiscoveryState::Discovered) + .count(), + MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS + ); + assert_eq!(result.coverage_issues.len(), 1); + let capacity_gap = &result.coverage_issues[0]; + assert_eq!( + capacity_gap.state, + SccmClientDiscoveryCoverageIssueState::DeclarationLimitExceeded + ); + assert_eq!( + capacity_gap.omitted_declaration_state, + Some(SccmClientDiscoveryState::Discovered) + ); + assert_eq!(capacity_gap.occurrence_count.get(), 1); +} + +#[test] +fn discovery_at_the_exact_global_boundary_does_not_manufacture_a_gap() { + let observations = (1..=MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS as u32) + .map(|number| { + observation( + ROOT_A, + &format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number), + SccmClientDiscoveryObservationState::Found, + ) + }) + .collect(); + + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }) + .expect("valid observations"); + + assert_eq!( + result.declarations.len(), + MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS + ); + assert!(result + .declarations + .iter() + .all(|declaration| declaration.state == SccmClientDiscoveryState::Discovered)); +} + +#[test] +fn discovery_global_capacity_preserves_explicit_states_and_reports_a_coverage_gap() { + let mut observations = (1..=MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS as u32) + .map(|number| { + observation( + ROOT_A, + &format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number), + SccmClientDiscoveryObservationState::AccessDenied, + ) + }) + .collect::>(); + observations.push(observation( + ROOT_B, + "ScanAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Skipped, + )); + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }; + + let result = + discover_client_sources(&input).expect("global capacity remains a bounded coverage result"); + let mut reversed = input; + reversed.observations.reverse(); + let reversed = discover_client_sources(&reversed) + .expect("capacity selection is independent of observation order"); + + assert_eq!(result, reversed); + assert_eq!( + result.declarations.len(), + MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS + ); + assert!(result.declarations.iter().any(|declaration| { + declaration.basename == "ScanAgent.log" + && declaration.state == SccmClientDiscoveryState::Skipped + })); + assert!(result + .declarations + .iter() + .all(|declaration| declaration.state != SccmClientDiscoveryState::Capped)); + assert_eq!( + result.coverage_issues.len(), + 1, + "one omitted explicit declaration becomes one coverage gap" + ); + let capacity_gap = &result.coverage_issues[0]; + assert_eq!( + capacity_gap.state, + SccmClientDiscoveryCoverageIssueState::DeclarationLimitExceeded, + "capacity has a dedicated coverage state" + ); + assert_eq!( + capacity_gap.omitted_declaration_state, + Some(SccmClientDiscoveryState::AccessDenied), + "the privacy-safe gap retains the omitted physical fact's actual state" + ); + assert_eq!(capacity_gap.catalog_entry_id, "sccm-client-source:v1:none"); + assert!(capacity_gap.logical_artifact_ids.is_empty()); + assert_eq!( + capacity_gap.rotation_category, + SccmClientDiscoveryRotationCategory::Unknown + ); + assert_eq!(capacity_gap.occurrence_count.get(), 1); +} + +#[test] +fn discovery_capacity_gap_retains_an_omitted_per_source_capped_state() { + let mut observations = (1..=MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS as u32) + .map(|number| { + observation( + ROOT_A, + &format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number), + SccmClientDiscoveryObservationState::Found, + ) + }) + .collect::>(); + observations.push(observation( + ROOT_B, + "ScanAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Skipped, + )); + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS - 1, + observations, + }; + + let result = discover_client_sources(&input) + .expect("per-source and global capacity remain explicit coverage"); + let mut reversed_input = input; + reversed_input.observations.reverse(); + let reversed = discover_client_sources(&reversed_input) + .expect("capacity coverage remains order independent"); + + assert_eq!(result, reversed); + assert_eq!( + result.coverage_issues.len(), + 1, + "the globally omitted per-source marker needs an explicit capacity issue" + ); + assert_eq!( + result.coverage_issues[0].omitted_declaration_state, + Some(SccmClientDiscoveryState::Capped), + "the capacity issue must retain the omitted declaration's Capped state" + ); + assert!(result.declarations.iter().any(|declaration| { + declaration.basename == "ScanAgent.log" + && declaration.state == SccmClientDiscoveryState::Skipped + })); + assert!(result + .declarations + .iter() + .all(|declaration| declaration.state != SccmClientDiscoveryState::Capped)); +} + +#[test] +fn discovery_capacity_gaps_retain_each_omitted_nonfound_state() { + for (omitted_input_state, expected_omitted_state, terminal_state, expected_terminal_state) in [ + ( + SccmClientDiscoveryObservationState::NotFound, + SccmClientDiscoveryState::NotFound, + SccmClientDiscoveryObservationState::Skipped, + SccmClientDiscoveryState::Skipped, + ), + ( + SccmClientDiscoveryObservationState::Skipped, + SccmClientDiscoveryState::Skipped, + SccmClientDiscoveryObservationState::NotFound, + SccmClientDiscoveryState::NotFound, + ), + ] { + let mut observations = (1..=MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS as u32) + .map(|number| { + observation( + ROOT_A, + &format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number), + omitted_input_state, + ) + }) + .collect::>(); + observations.push(observation( + ROOT_B, + "ScanAgent.log", + SccmRotation::Current, + terminal_state, + )); + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }; + + let result = discover_client_sources(&input).expect("bounded explicit-state coverage"); + let mut reversed_input = input; + reversed_input.observations.reverse(); + let reversed = discover_client_sources(&reversed_input) + .expect("explicit-state capacity coverage is order independent"); + + assert_eq!(result, reversed); + assert_eq!( + result.declarations.len(), + MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS + ); + assert!(result.declarations.iter().any(|declaration| { + declaration.basename == "ScanAgent.log" && declaration.state == expected_terminal_state + })); + assert!(result + .declarations + .iter() + .all(|declaration| declaration.state != SccmClientDiscoveryState::Capped)); + assert_eq!(result.coverage_issues.len(), 1); + let capacity_gap = &result.coverage_issues[0]; + assert_eq!( + capacity_gap.state, + SccmClientDiscoveryCoverageIssueState::DeclarationLimitExceeded + ); + assert_eq!( + capacity_gap.omitted_declaration_state, + Some(expected_omitted_state) + ); + assert_eq!(capacity_gap.occurrence_count.get(), 1); + assert!(!format!("{capacity_gap:?}").contains(ROOT_A)); + assert!(!format!("{capacity_gap:?}").contains("AppEnforce.log")); + } +} + +#[test] +fn discovery_enforces_each_source_cap_and_retains_the_first_omitted_rotation_gap() { + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 2, + observations: vec![ + observation( + ROOT_A, + "AppEnforce.log.2", + SccmRotation::Numbered(2), + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "AppEnforce.lo_", + SccmRotation::LoUnderscore, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + ], + }) + .expect("valid observations"); + + assert_eq!( + result + .declarations + .iter() + .map(|declaration| (&declaration.rotation, declaration.state)) + .collect::>(), + vec![ + (&SccmRotation::Current, SccmClientDiscoveryState::Discovered), + ( + &SccmRotation::LoUnderscore, + SccmClientDiscoveryState::Discovered + ), + (&SccmRotation::Numbered(2), SccmClientDiscoveryState::Capped), + ] + ); + let fingerprint = path_fingerprint(ROOT_A, "AppEnforce.log"); + assert_eq!( + result.declarations[0].artifact_id, + expected_physical_artifact_id(&fingerprint, &SccmRotation::Current, "AppEnforce.log") + ); + assert_eq!( + result.declarations[2].artifact_id, + expected_marker_artifact_id( + "AppEnforce.log", + "capped", + &fingerprint, + &SccmRotation::Numbered(2), + "AppEnforce.log.2", + ) + ); +} + +#[test] +fn discovery_marks_only_the_first_found_fragment_per_source_when_the_cap_is_zero() { + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 0, + observations: vec![ + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "AppEnforce.lo_", + SccmRotation::LoUnderscore, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_B, + "PolicyAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_B, + "PolicyAgent.lo_", + SccmRotation::LoUnderscore, + SccmClientDiscoveryObservationState::Found, + ), + ], + }) + .expect("zero cap is an explicit per-source coverage boundary"); + + assert_eq!( + result + .declarations + .iter() + .map(|declaration| { + ( + declaration.root_handle.as_str(), + declaration.basename.as_str(), + declaration.state, + ) + }) + .collect::>(), + vec![ + (ROOT_A, "AppEnforce.log", SccmClientDiscoveryState::Capped), + (ROOT_B, "PolicyAgent.log", SccmClientDiscoveryState::Capped), + ] + ); +} + +#[test] +fn discovery_preserves_denied_and_not_found_coverage_with_stable_collision_safe_identities() { + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + ROOT_B, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "CIAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::AccessDenied, + ), + observation( + ROOT_B, + "ScanAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::NotFound, + ), + ], + }; + let result = discover_client_sources(&input).expect("valid observations"); + let reversed = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: input.max_found_fragments_per_source, + observations: input.observations.into_iter().rev().collect(), + }) + .expect("valid observations"); + + assert_eq!(result.declarations, reversed.declarations); + assert!(result + .declarations + .iter() + .any(|declaration| declaration.state == SccmClientDiscoveryState::AccessDenied)); + assert!(result + .declarations + .iter() + .any(|declaration| declaration.state == SccmClientDiscoveryState::NotFound)); + + let collisions = result + .declarations + .iter() + .filter(|declaration| { + declaration.basename == "AppEnforce.log" + && declaration.rotation == SccmRotation::Current + }) + .collect::>(); + assert_eq!(collisions.len(), 2); + assert_ne!(collisions[0].artifact_id, collisions[1].artifact_id); + assert_ne!( + collisions[0].evidence_identity, + collisions[1].evidence_identity + ); + assert_ne!( + collisions[0].path_fingerprint, + collisions[1].path_fingerprint + ); + for collision in collisions { + assert_eq!( + collision.artifact_id, + expected_physical_artifact_id( + &path_fingerprint(&collision.root_handle, "AppEnforce.log"), + &SccmRotation::Current, + "AppEnforce.log", + ) + ); + } + let denied = result + .declarations + .iter() + .find(|declaration| declaration.state == SccmClientDiscoveryState::AccessDenied) + .expect("access-denied observation remains explicit"); + assert_eq!( + denied.artifact_id, + expected_marker_artifact_id( + "CIAgent.log", + "accessDenied", + &path_fingerprint(ROOT_A, "CIAgent.log"), + &SccmRotation::Current, + "CIAgent.log", + ) + ); + let missing = result + .declarations + .iter() + .find(|declaration| declaration.state == SccmClientDiscoveryState::NotFound) + .expect("not-found observation remains explicit"); + assert_eq!( + missing.artifact_id, + expected_marker_artifact_id( + "ScanAgent.log", + "absent", + &path_fingerprint(ROOT_B, "ScanAgent.log"), + &SccmRotation::Current, + "ScanAgent.log", + ) + ); + assert!(result.declarations.iter().all(|declaration| { + !declaration.artifact_id.contains(ROOT_A) + && !declaration.artifact_id.contains(ROOT_B) + && !declaration.evidence_identity.contains(ROOT_A) + && !declaration.evidence_identity.contains(ROOT_B) + && !declaration.path_fingerprint.contains(ROOT_A) + && !declaration.path_fingerprint.contains(ROOT_B) + })); +} + +#[test] +fn discovery_coalesces_exact_duplicate_observations_without_spending_global_quota() { + let duplicate = observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ); + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 1, + observations: vec![duplicate.clone(), duplicate], + }) + .expect("exact duplicates are valid"); + + assert_eq!(result.declarations.len(), 1); + assert_eq!( + result.declarations[0].state, + SccmClientDiscoveryState::Discovered + ); +} + +#[test] +fn discovery_rejects_conflicting_states_for_one_canonical_physical_source() { + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::AccessDenied, + ), + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::NotFound, + ), + ], + }; + + let error = + discover_client_sources(&input).expect_err("contradictory physical evidence fails closed"); + assert_eq!( + error.to_string(), + "conflicting SCCM client discovery observations" + ); + assert!(!error.to_string().contains(ROOT_A)); + assert!(!error.to_string().contains("AppEnforce.log")); + let mut reversed = input; + reversed.observations.reverse(); + let reversed_error = discover_client_sources(&reversed) + .expect_err("contradictory physical evidence fails closed regardless of order"); + + assert_eq!(error, reversed_error); +} + +#[test] +fn discovery_rejects_accepted_and_rejected_facts_for_one_raw_physical_observation() { + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "AppEnforce.log", + unsupported_rotation(".backup"), + SccmClientDiscoveryObservationState::AccessDenied, + ), + ], + }; + + let error = discover_client_sources(&input) + .expect_err("classification disagreement cannot split one raw physical observation"); + let mut reversed = input; + reversed.observations.reverse(); + let reversed_error = discover_client_sources(&reversed) + .expect_err("accepted/rejected conflicts remain order independent"); + + assert_eq!(error, SccmClientDiscoveryError::ConflictingObservation); + assert_eq!(reversed_error, error); + assert!(!error.to_string().contains(ROOT_A)); + assert!(!error.to_string().contains("AppEnforce.log")); +} + +#[test] +fn discovery_rejects_accepted_and_rejected_dispositions_for_one_same_state_observation() { + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "AppEnforce.log", + unsupported_rotation(".backup"), + SccmClientDiscoveryObservationState::Found, + ), + ], + }; + + let error = discover_client_sources(&input) + .expect_err("classification disagreement fails closed even when states match"); + let mut reversed = input; + reversed.observations.reverse(); + let reversed_error = discover_client_sources(&reversed) + .expect_err("same-state disposition conflicts remain order independent"); + + assert_eq!(error, SccmClientDiscoveryError::ConflictingObservation); + assert_eq!(reversed_error, error); + assert!(!error.to_string().contains(ROOT_A)); + assert!(!error.to_string().contains("AppEnforce.log")); +} + +#[test] +fn discovery_rejects_late_conflicts_after_the_global_declaration_frontier() { + let mut observations = Vec::new(); + for number in 1..=2_047 { + observations.push(observation( + ROOT_A, + &format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number), + SccmClientDiscoveryObservationState::Found, + )); + observations.push(observation( + ROOT_B, + &format!("PolicyAgent.log.{number}"), + SccmRotation::Numbered(number), + SccmClientDiscoveryObservationState::Found, + )); + } + observations.push(observation( + ROOT_A, + "AppEnforce.log.2048", + SccmRotation::Numbered(2_048), + SccmClientDiscoveryObservationState::Found, + )); + observations.extend([ + observation( + ROOT_A, + "CIAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "CIAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::AccessDenied, + ), + ]); + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }; + + let error = discover_client_sources(&input) + .expect_err("a conflict beyond the output frontier fails closed"); + assert_eq!(error, SccmClientDiscoveryError::ConflictingObservation); + let mut reversed = input; + reversed.observations.reverse(); + assert_eq!( + error, + discover_client_sources(&reversed) + .expect_err("a late conflict fails closed regardless of input order") + ); +} + +#[test] +fn discovery_rejects_conflicting_states_for_canonical_basename_aliases() { + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "appenforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::AccessDenied, + ), + ], + }; + + let error = discover_client_sources(&input) + .expect_err("canonical aliases with conflicting state fail closed"); + assert_eq!(error, SccmClientDiscoveryError::ConflictingObservation); + let mut reversed = input; + reversed.observations.reverse(); + assert_eq!( + error, + discover_client_sources(&reversed) + .expect_err("canonical alias conflict fails closed regardless of input order") + ); +} + +#[test] +fn discovery_canonicalizes_supported_aliases_into_stable_physical_declarations() { + for (canonical_basename, alias, rotation, physical_basename) in [ + ( + "AppEnforce.log", + "appenforce.log", + SccmRotation::Current, + "AppEnforce.log", + ), + ( + "AppEnforce.log", + "appenforce.lo_", + SccmRotation::LoUnderscore, + "AppEnforce.lo_", + ), + ( + "AppEnforce.log", + "appenforce.log.7", + SccmRotation::Numbered(7), + "AppEnforce.log.7", + ), + ] { + let canonical = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![observation( + ROOT_A, + physical_basename, + rotation.clone(), + SccmClientDiscoveryObservationState::Found, + )], + }) + .expect("canonical observation is supported"); + let alias = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![observation( + ROOT_A, + alias, + rotation.clone(), + SccmClientDiscoveryObservationState::Found, + )], + }) + .expect("case-equivalent observation is supported"); + + assert_eq!(alias.declarations, canonical.declarations); + let declaration = &alias.declarations[0]; + assert_eq!(declaration.basename, physical_basename); + assert_eq!( + declaration.artifact_id, + expected_physical_artifact_id( + &path_fingerprint(ROOT_A, canonical_basename), + &rotation, + physical_basename, + ) + ); + assert_eq!( + declaration.evidence_identity, + expected_evidence_identity(canonical_basename, ROOT_A, &rotation, physical_basename,) + ); + assert_eq!( + declaration.evidence_identity.as_bytes(), + canonical.declarations[0].evidence_identity.as_bytes(), + "equivalent aliases preserve byte-identical evidence IDs" + ); + } +} + +#[test] +fn discovery_rejects_observations_beyond_its_defensive_contract() { + let observations = (1..=MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS + 1) + .map(|number| { + observation( + ROOT_A, + &format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number as u32), + SccmClientDiscoveryObservationState::Found, + ) + }) + .collect(); + let error = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }) + .expect_err("the API must not silently ignore input beyond its defensive contract"); + + assert_eq!(error, SccmClientDiscoveryError::ObservationLimitExceeded); + assert_eq!( + error.to_string(), + "SCCM client discovery observation limit exceeded" + ); + assert!(!error.to_string().contains(ROOT_A)); +} + +#[test] +fn discovery_preserves_valid_coverage_and_reports_invalid_provenance_without_raw_roots() { + let malformed_root = "C:\\private\\SCCM\\Client\\Logs"; + let escaped_malformed_root = malformed_root.escape_debug().to_string(); + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + malformed_root, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_B, + "Unrelated.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "AnotherUnrelated.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_B, + "Unrelated.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_B, + "AppEnforce.log.1", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "CIAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Skipped, + ), + observation( + ROOT_A, + "ScanAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::NotFound, + ), + observation( + ROOT_B, + "PolicyAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + ], + }; + let result = discover_client_sources(&input) + .expect("invalid observations remain explicit coverage without aborting valid sources"); + let reversed = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: input.max_found_fragments_per_source, + observations: input.observations.into_iter().rev().collect(), + }) + .expect("coverage results remain deterministic under input reversal"); + + assert_eq!(result, reversed); + assert_eq!( + result + .declarations + .iter() + .map(|declaration| (declaration.basename.as_str(), declaration.state)) + .collect::>(), + vec![ + ("PolicyAgent.log", SccmClientDiscoveryState::Discovered), + ("CIAgent.log", SccmClientDiscoveryState::Skipped), + ("ScanAgent.log", SccmClientDiscoveryState::NotFound), + ], + "skipped, absent, and found remain separate coverage states" + ); + assert_eq!(result.coverage_issues.len(), 2); + let invalid_provenance = result + .coverage_issues + .iter() + .find(|issue| issue.state == SccmClientDiscoveryCoverageIssueState::InvalidProvenance) + .expect("known source with malformed provenance is explicit"); + assert_eq!( + invalid_provenance.catalog_entry_id, + expected_catalog_entry_id("AppEnforce.log") + ); + assert!( + invalid_provenance.logical_artifact_ids.is_empty(), + "rejected provenance cannot assert derived workflow membership" + ); + assert_eq!( + invalid_provenance.rotation_category, + SccmClientDiscoveryRotationCategory::Unknown, + "rejected provenance cannot retain caller-supplied rotation trust" + ); + assert_eq!(invalid_provenance.omitted_declaration_state, None); + let unsupported = result + .coverage_issues + .iter() + .filter(|issue| issue.state == SccmClientDiscoveryCoverageIssueState::Unsupported) + .collect::>(); + assert_eq!(unsupported.len(), 1); + assert!(unsupported + .iter() + .all(|issue| issue.logical_artifact_ids.is_empty())); + assert_eq!( + unsupported + .iter() + .find(|issue| issue.catalog_entry_id == "sccm-client-source:v1:none") + .expect("arbitrary supplied names have one privacy-safe unsupported category") + .occurrence_count + .get(), + 4, + "coalesced unsupported metadata retains the bounded count of supplied observations" + ); + assert!(result.coverage_issues.iter().all(|issue| { + !format!("{issue:?}").contains(escaped_malformed_root.as_str()) + && !issue.artifact_id.contains(malformed_root) + && !issue.catalog_entry_id.contains(malformed_root) + })); + let distinct_malformed_root = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![observation( + "root-also-not-validated", + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + )], + }) + .expect("invalid provenance remains coverage-only"); + assert_eq!( + distinct_malformed_root.coverage_issues[0].artifact_id, invalid_provenance.artifact_id, + "invalid-root identities must not hash or otherwise depend on raw root input" + ); + assert!(result.declarations.iter().all(|declaration| result + .coverage_issues + .iter() + .all(|issue| declaration.artifact_id != issue.artifact_id))); +} + +#[test] +fn discovery_retains_coverage_issues_past_the_declaration_cap_without_admitting_them_as_capture() { + let mut observations = (1..=MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS) + .map(|number| { + observation( + ROOT_A, + &format!("AppEnforce.log.{number}"), + SccmRotation::Numbered(number as u32), + SccmClientDiscoveryObservationState::Found, + ) + }) + .collect::>(); + observations.push(observation( + "not-a-root-handle", + "CIAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + )); + + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }) + .expect("a malformed observation cannot hide coverage behind declaration capping"); + + assert_eq!( + result.declarations.len(), + MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS + ); + assert_eq!(result.coverage_issues.len(), 1); + assert!(result.coverage_issues.len() <= MAX_SCCM_CLIENT_DISCOVERY_COVERAGE_ISSUES); + assert_eq!( + result.coverage_issues[0].state, + SccmClientDiscoveryCoverageIssueState::InvalidProvenance + ); + assert_eq!(result.coverage_issues[0].occurrence_count.get(), 1); + assert!(result + .declarations + .iter() + .all(|declaration| declaration.artifact_id != result.coverage_issues[0].artifact_id)); +} + +#[test] +fn discovery_preserves_coverage_issue_cardinality_at_the_exact_admission_boundary() { + let observations = (0..MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS) + .map(|_| { + observation( + ROOT_A, + "Unrelated.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ) + }) + .collect(); + + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations, + }) + .expect("the admission boundary retains every coverage-only observation"); + + assert!(result.declarations.is_empty()); + assert_eq!(result.coverage_issues.len(), 1); + let single = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, + observations: vec![observation( + ROOT_A, + "Unrelated.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + )], + }) + .expect("one unsupported observation remains explicit"); + assert_eq!( + result.coverage_issues[0].artifact_id, single.coverage_issues[0].artifact_id, + "coverage issue identity must not depend on its aggregated count" + ); + assert_eq!( + result.coverage_issues[0].occurrence_count.get() as usize, + MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS, + "privacy-safe issue coalescing must retain exact duplicate cardinality" + ); +} + +#[test] +fn discovery_never_assigns_catalog_memberships_to_rejected_rotation_candidates() { + let raw_root = "C:\\private\\ccm\\logs"; + let escaped_raw_root = raw_root.escape_debug().to_string(); + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + raw_root, + "PolicyAgent.log.backup", + unsupported_rotation(".backup"), + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_A, + "PolicyAgent.log.1", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_B, + "PolicyAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + ], + }; + + let result = discover_client_sources(&input).expect("rejected candidates remain coverage-only"); + let reversed = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: input.max_found_fragments_per_source, + observations: input.observations.into_iter().rev().collect(), + }) + .expect("rejected candidate coverage is order independent"); + + assert_eq!(result, reversed); + assert_eq!(result.declarations.len(), 1); + assert_eq!(result.coverage_issues.len(), 2); + assert!(result.coverage_issues.iter().all(|issue| { + issue.catalog_entry_id == "sccm-client-source:v1:none" + && issue.logical_artifact_ids.is_empty() + && issue.rotation_category == SccmClientDiscoveryRotationCategory::Unknown + && issue.omitted_declaration_state.is_none() + && !format!("{issue:?}").contains(escaped_raw_root.as_str()) + && !format!("{issue:?}").contains("PolicyAgent.log.backup") + && !format!("{issue:?}").contains("PolicyAgent.log.1") + })); +} + +#[test] +fn discovery_rejects_conflicting_states_for_the_same_rejected_physical_observation() { + let raw_root = "C:\\private\\ccm\\logs"; + let raw_basename = "PolicyAgent.log.backup"; + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + raw_root, + raw_basename, + unsupported_rotation(".backup"), + SccmClientDiscoveryObservationState::Found, + ), + observation( + raw_root, + raw_basename, + unsupported_rotation(".backup"), + SccmClientDiscoveryObservationState::AccessDenied, + ), + observation( + ROOT_A, + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + ], + }; + + let error = discover_client_sources(&input) + .expect_err("one rejected physical observation cannot carry contradictory states"); + let mut reversed = input; + reversed.observations.reverse(); + let reversed_error = discover_client_sources(&reversed) + .expect_err("rejected physical conflicts remain order independent"); + + assert_eq!(error, SccmClientDiscoveryError::ConflictingObservation); + assert_eq!(reversed_error, error); + assert!(!error.to_string().contains(raw_root)); + assert!(!error.to_string().contains(raw_basename)); +} + +#[test] +fn discovery_does_not_conflict_distinct_rejected_alias_spellings() { + let raw_root = "C:\\private\\ccm\\logs"; + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + raw_root, + "PolicyAgent.log.backup", + unsupported_rotation(".backup"), + SccmClientDiscoveryObservationState::Found, + ), + observation( + raw_root, + "policyagent.log.backup", + unsupported_rotation(".backup"), + SccmClientDiscoveryObservationState::AccessDenied, + ), + ], + }; + + let result = discover_client_sources(&input) + .expect("distinct raw rejected spellings are not one physical observation"); + let reversed = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: input.max_found_fragments_per_source, + observations: input.observations.into_iter().rev().collect(), + }) + .expect("distinct rejected aliases remain nonconflicting under reversal"); + + assert_eq!(result, reversed); + assert!(result.declarations.is_empty()); + assert_eq!(result.coverage_issues.len(), 1); + assert_eq!(result.coverage_issues[0].occurrence_count.get(), 2); + assert_eq!( + result.coverage_issues[0].catalog_entry_id, + "sccm-client-source:v1:none" + ); + assert!(result.coverage_issues[0].logical_artifact_ids.is_empty()); +} + +#[test] +fn discovery_conflicts_rejected_states_even_when_untrusted_rotations_differ() { + let raw_root = "C:\\private\\ccm\\logs"; + let input = SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + raw_root, + "PolicyAgent.log.backup", + unsupported_rotation(".backup"), + SccmClientDiscoveryObservationState::Found, + ), + observation( + raw_root, + "PolicyAgent.log.backup", + unsupported_rotation(".archive"), + SccmClientDiscoveryObservationState::AccessDenied, + ), + ], + }; + + let error = discover_client_sources(&input) + .expect_err("rejected physical identity ignores caller-supplied rotation metadata"); + let mut reversed = input; + reversed.observations.reverse(); + let reversed_error = discover_client_sources(&reversed) + .expect_err("rejected rotation metadata cannot make conflicts order-dependent"); + + assert_eq!(error, SccmClientDiscoveryError::ConflictingObservation); + assert_eq!(reversed_error, error); + assert!(!error.to_string().contains(raw_root)); + assert!(!error.to_string().contains("PolicyAgent.log.backup")); +} diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs new file mode 100644 index 000000000..72446a119 --- /dev/null +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -0,0 +1,683 @@ +use std::fs; +use std::path::Path; + +use app_lib::sccm::{ + read_sccm_client_intake_bundle, read_sccm_manifest_or_legacy, SccmBundleManifestV1, + SccmManifestProvenance, SccmManifestSourceState, MAX_SCCM_MANIFEST_ARTIFACTS, + SCCM_MANIFEST_FILE_NAME, +}; +use cmtraceopen_parser::sccm::{ + admit_client_evidence, assess_client_intake, SccmClientCapturedPayload, + SccmClientEvidenceAdmissionError, SccmCoverageState, SccmRotation, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tempfile::tempdir; + +const COLLECTED_AT_UTC: &str = "2026-07-30T15:00:00Z"; +const CONFIGMGR_VERSION: &str = "5.00.TEST.0000"; +const POLICY_BASENAME: &str = "PolicyAgent.log"; +const POLICY_GROUP: &str = "client-policy-agent"; +const ROOT_HANDLE: &str = "root-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const HOST_HANDLE: &str = concat!( + "cmtraceopen.host.hmac-sha256.v1:", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +); + +fn sha256(value: &[u8]) -> String { + Sha256::digest(value) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn catalog_entry_id() -> String { + catalog_entry_id_for(POLICY_BASENAME) +} + +fn catalog_entry_id_for(basename: &str) -> String { + format!( + "sccm-client-source:v1:sha256:{}", + sha256(basename.as_bytes()) + ) +} + +fn source_digest() -> String { + let root_digest = ROOT_HANDLE + .strip_prefix("root-") + .expect("fixture root handle is versioned"); + sha256(format!("cmtraceopen.sccm.source.v1\0{root_digest}\0{POLICY_BASENAME}").as_bytes()) +} + +fn source_handle() -> String { + format!("cmtraceopen.source.sha256.v1:{}", source_digest()) +} + +fn path_fingerprint() -> String { + format!("sha256:{}", source_digest()) +} + +fn rotation_lineage() -> String { + format!( + "cmtraceopen.lineage.sha256.v1:{}", + sha256(format!("lineage:v1:{}", source_digest()).as_bytes()) + ) +} + +fn rotation_basename(rotation: &SccmRotation) -> String { + match rotation { + SccmRotation::Current => POLICY_BASENAME.to_owned(), + SccmRotation::LoUnderscore => "PolicyAgent.lo_".to_owned(), + SccmRotation::Numbered(number) => format!("{POLICY_BASENAME}.{number}"), + SccmRotation::Timestamped(timestamp) => format!("{POLICY_BASENAME}.{timestamp}"), + SccmRotation::Unknown(_) => panic!("fixture never uses unknown rotations"), + } +} + +fn rotation_segment(rotation: &SccmRotation) -> String { + match rotation { + SccmRotation::Current => "current".to_owned(), + SccmRotation::LoUnderscore => "lo".to_owned(), + SccmRotation::Numbered(number) => format!("numbered-{number}"), + SccmRotation::Timestamped(timestamp) => format!("timestamped-{timestamp}"), + SccmRotation::Unknown(_) => panic!("fixture never uses unknown rotations"), + } +} + +fn relative_path(rotation: &SccmRotation) -> String { + format!( + "evidence/sccm/client/{POLICY_GROUP}/{ROOT_HANDLE}/{}/{}", + rotation_segment(rotation), + rotation_basename(rotation) + ) +} + +#[derive(Clone)] +struct PhysicalArtifactFixture { + value: Value, + content: Vec, +} + +fn physical_artifact(rotation: SccmRotation, content: &[u8]) -> PhysicalArtifactFixture { + let basename = rotation_basename(&rotation); + let fingerprint = path_fingerprint(); + let artifact_id = format!( + "sccm-artifact:v1:sha256:{}", + sha256( + format!( + "artifact:v1:{fingerprint}:{}:{basename}", + rotation_segment(&rotation) + ) + .as_bytes() + ) + ); + PhysicalArtifactFixture { + value: json!({ + "catalogEntryId": catalog_entry_id(), + "logicalArtifactIds": [POLICY_GROUP], + "artifactId": artifact_id, + "role": "client", + "sourceHandle": source_handle(), + "rootHandle": ROOT_HANDLE, + "pathFingerprint": fingerprint, + "rotationLineage": rotation_lineage(), + "relativePath": relative_path(&rotation), + "basename": basename, + "rotation": rotation, + "state": "captured", + "coverageScope": "source", + "bytesCopied": content.len(), + "contentSha256": sha256(content), + "fragmentComplete": false, + "configmgrVersion": CONFIGMGR_VERSION, + "collectedAtUtc": COLLECTED_AT_UTC, + "encoding": "utf-8" + }), + content: content.to_vec(), + } +} + +fn capture_gap(rotation: SccmRotation, state: &str) -> Value { + let basename = rotation_basename(&rotation); + let fingerprint = path_fingerprint(); + let artifact_id = format!( + "sccm-artifact:v1:sha256:{}", + sha256( + format!( + "marker:v1:{}:{state}:{}:{basename}:{fingerprint}", + catalog_entry_id(), + rotation_segment(&rotation) + ) + .as_bytes() + ) + ); + json!({ + "artifactId": artifact_id, + "catalogEntryId": catalog_entry_id(), + "logicalArtifactIds": [POLICY_GROUP], + "sourceHandle": source_handle(), + "rootHandle": ROOT_HANDLE, + "pathFingerprint": fingerprint, + "rotationLineage": rotation_lineage(), + "basename": basename, + "rotation": rotation, + "state": state, + "captureLimitKind": "fileCount", + "sourceBytes": 2048, + "bytesRetained": 0 + }) +} + +fn native_manifest(artifacts: Vec, capture_gaps: Vec) -> Value { + json!({ + "sccmManifestVersion": 1, + "diagnosticsSchemaVersion": 1, + "sourceCatalogVersion": 1, + "provenance": "nativeClientCapture", + "provenanceProfile": "hmacSha256V1", + "hostHandle": HOST_HANDLE, + "collectedAtUtc": COLLECTED_AT_UTC, + "maxFilesPerSource": 8, + "maxBytesPerSource": 4096, + "artifacts": artifacts, + "captureGaps": capture_gaps + }) +} + +fn make_private_directory(path: &Path) { + fs::create_dir_all(path).expect("create fixture directory"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .expect("make fixture directory private"); + } +} + +fn write_native_bundle( + root: &Path, + artifacts: &[&PhysicalArtifactFixture], + capture_gaps: &[Value], +) { + make_private_directory(root); + for artifact in artifacts { + let relative_path = artifact.value["relativePath"] + .as_str() + .expect("physical fixture path"); + let destination = root.join(relative_path); + fs::create_dir_all(destination.parent().expect("evidence parent")) + .expect("create evidence tree"); + fs::write(destination, &artifact.content).expect("write synthetic evidence"); + } + let manifest = native_manifest( + artifacts + .iter() + .map(|artifact| artifact.value.clone()) + .collect(), + capture_gaps.to_vec(), + ); + fs::write( + root.join(SCCM_MANIFEST_FILE_NAME), + serde_json::to_vec_pretty(&manifest).expect("serialize fixture manifest"), + ) + .expect("write fixture manifest"); +} + +fn remove_written_evidence(root: &Path, artifact: &PhysicalArtifactFixture) { + let relative_path = artifact.value["relativePath"] + .as_str() + .expect("physical fixture path"); + fs::remove_file(root.join(relative_path)).expect("remove evidence after writing manifest"); +} + +fn set_native_limits(root: &Path, max_files: u64, max_bytes: u64) { + let manifest_path = root.join(SCCM_MANIFEST_FILE_NAME); + let mut manifest: Value = serde_json::from_slice( + &fs::read(&manifest_path).expect("read synthetic manifest for limit mutation"), + ) + .expect("synthetic manifest is JSON"); + manifest["maxFilesPerSource"] = json!(max_files); + manifest["maxBytesPerSource"] = json!(max_bytes); + fs::write( + manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize limit-mutated manifest"), + ) + .expect("write limit-mutated manifest"); +} + +#[test] +fn validated_v1_reader_projects_one_physical_client_artifact() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let current = physical_artifact(SccmRotation::Current, b"policy-current"); + write_native_bundle(&bundle_root, &[¤t], &[]); + + let manifest = read_sccm_manifest_or_legacy(&bundle_root).expect("validated manifest"); + let bundle = read_sccm_client_intake_bundle(&bundle_root).expect("verified pure projection"); + + assert_eq!(manifest.sccm_manifest_version, 1); + assert_eq!( + manifest.provenance, + SccmManifestProvenance::NativeClientCapture + ); + assert_eq!(bundle.artifacts.len(), 1); + assert!(bundle.capture_gaps.is_empty()); + let physical = &bundle.artifacts[0]; + assert_eq!(physical.artifact.display_name, POLICY_BASENAME); + assert_eq!(physical.artifact.coverage, SccmCoverageState::Captured); + assert_eq!(physical.artifact.original_path, None); + assert_eq!(physical.artifact.host, None); + assert_eq!( + physical.relative_path.as_deref(), + Some(relative_path(&SccmRotation::Current).as_str()) + ); + assert_eq!(physical.fragment_complete, Some(false)); +} + +#[test] +fn native_projection_preserves_completeness_without_sealing_content_binding() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let exact_bytes = b"complete-policy-current"; + let mut current = physical_artifact(SccmRotation::Current, exact_bytes); + current.value["fragmentComplete"] = json!(true); + write_native_bundle(&bundle_root, &[¤t], &[]); + + let bundle = read_sccm_client_intake_bundle(&bundle_root).expect("verified pure projection"); + let projected = &bundle.artifacts[0]; + assert_eq!(projected.fragment_complete, Some(true)); + assert_eq!(projected.declared_byte_length, None); + assert_eq!(projected.content_sha256, None); + + let assessment = assess_client_intake(&bundle).expect("native projection remains canonical"); + let payload = SccmClientCapturedPayload::new( + projected.artifact.artifact_id.clone(), + exact_bytes.to_vec(), + ) + .expect("exact manifest-validated bytes form a bounded payload"); + assert!(matches!( + admit_client_evidence(&bundle, &assessment, &[payload]), + Err(SccmClientEvidenceAdmissionError::MissingContentBinding) + )); +} + +#[test] +fn omitted_capped_rotation_projects_as_a_gap_without_a_fake_fragment() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let current = physical_artifact(SccmRotation::Current, b"policy-current"); + let omitted = capture_gap(SccmRotation::Numbered(1), "capped"); + write_native_bundle(&bundle_root, &[¤t], std::slice::from_ref(&omitted)); + + let bundle = read_sccm_client_intake_bundle(&bundle_root).expect("projected bundle"); + assert_eq!( + bundle.artifacts.len(), + 1, + "a gap is not a zero-byte artifact" + ); + assert_eq!(bundle.capture_gaps.len(), 1); + assert_eq!(bundle.capture_gaps[0].basename, "PolicyAgent.log.1"); + assert_eq!(bundle.capture_gaps[0].rotation, SccmRotation::Numbered(1)); + assert_eq!(bundle.capture_gaps[0].coverage, SccmCoverageState::Capped); + + let assessment = assess_client_intake(&bundle).expect("assessment accepts native gap"); + let group = assessment + .groups + .iter() + .find(|group| group.logical_artifact_id == POLICY_GROUP) + .expect("policy group"); + assert_eq!(group.fragments.len(), 1); + assert_eq!(group.coverage, SccmCoverageState::Capped); + assert_eq!(assessment.capture_gaps, bundle.capture_gaps); +} + +#[test] +fn parse_failed_omitted_rotation_remains_coverage_only() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let current = physical_artifact(SccmRotation::Current, b"policy-current"); + let failed = capture_gap(SccmRotation::Numbered(2), "parseFailed"); + write_native_bundle(&bundle_root, &[¤t], std::slice::from_ref(&failed)); + let bundle = read_sccm_client_intake_bundle(&bundle_root).expect("verified pure projection"); + + assert_eq!(bundle.artifacts.len(), 1); + assert_eq!(bundle.capture_gaps.len(), 1); + assert_eq!( + bundle.capture_gaps[0].coverage, + SccmCoverageState::ParseFailed + ); + assert_eq!(bundle.capture_gaps[0].rotation, SccmRotation::Numbered(2)); +} + +#[test] +fn native_manifest_uses_one_shared_4096_entry_decode_ceiling() { + let gap = capture_gap(SccmRotation::Numbered(1), "capped"); + let boundary = native_manifest( + vec![physical_artifact(SccmRotation::Current, b"policy-current").value], + vec![gap.clone(); MAX_SCCM_MANIFEST_ARTIFACTS - 1], + ); + serde_json::from_value::(boundary) + .expect("combined 4096-entry boundary decodes"); + + let overflow = native_manifest( + vec![physical_artifact(SccmRotation::Current, b"policy-current").value], + vec![gap; MAX_SCCM_MANIFEST_ARTIFACTS], + ); + let error = serde_json::from_value::(overflow) + .expect_err("combined 4097-entry manifest is rejected during decoding"); + assert!(error + .to_string() + .contains("too many artifacts or capture gaps")); +} + +#[test] +fn reader_rejects_artifacts_outside_canonical_rotation_order() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let current = physical_artifact(SccmRotation::Current, b"policy-current"); + let numbered = physical_artifact(SccmRotation::Numbered(1), b"policy-rotation-one"); + write_native_bundle(&bundle_root, &[&numbered, ¤t], &[]); + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("manifest order is part of deterministic intake"); + assert!(error.to_string().contains("deterministic order")); +} + +#[test] +fn reader_rejects_duplicate_artifact_ids() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let current = physical_artifact(SccmRotation::Current, b"policy-current"); + write_native_bundle(&bundle_root, &[¤t, ¤t], &[]); + + let error = + read_sccm_manifest_or_legacy(&bundle_root).expect_err("colliding artifacts fail closed"); + assert!(error.to_string().contains("duplicate artifact IDs")); +} + +#[test] +fn legacy_projection_never_invents_native_capture_gaps_or_content_binding() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("legacy-bundle"); + make_private_directory(&bundle_root); + fs::write( + bundle_root.join("manifest.json"), + serde_json::to_vec_pretty(&json!({ + "collection": { + "collectorProfile": "cmtrace-full-diagnostics-v1", + "collectorVersion": "1.1.0", + "results": { + "gaps": [{ + "artifactId": "configmgr-ccm-logs", + "category": "logs", + "status": "Missing" + }] + } + }, + "artifacts": [] + })) + .expect("legacy JSON"), + ) + .expect("legacy manifest"); + + let manifest = read_sccm_manifest_or_legacy(&bundle_root).expect("legacy manifest view"); + let bundle = read_sccm_client_intake_bundle(&bundle_root).expect("legacy pure view"); + assert_eq!( + manifest.provenance, + SccmManifestProvenance::LegacyGenericUnscoped + ); + assert!(manifest.capture_gaps.is_empty()); + assert!(bundle.capture_gaps.is_empty()); + assert_eq!(bundle.artifacts.len(), 1); + assert_eq!(bundle.artifacts[0].declared_byte_length, None); + assert_eq!(bundle.artifacts[0].content_sha256, None); + let expected_catalog_id = catalog_entry_id_for("ccmsetup.log"); + let expected_artifact_id = format!( + "sccm-artifact:v1:sha256:{}", + sha256( + format!("marker:v1:{expected_catalog_id}:absent:current:ccmsetup.log:unscoped") + .as_bytes() + ) + ); + assert_eq!( + bundle.artifacts[0].artifact.artifact_id, + expected_artifact_id + ); +} + +#[test] +fn missing_and_malformed_legacy_errors_do_not_disclose_the_bundle_path() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("secret-bundle-root"); + make_private_directory(&bundle_root); + let sensitive_root = bundle_root.display().to_string(); + + let missing = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("an existing empty bundle has no supported manifest"); + assert!(!missing.to_string().contains(&sensitive_root)); + + fs::write(bundle_root.join("manifest.json"), b"{malformed").expect("malformed legacy manifest"); + let malformed = + read_sccm_manifest_or_legacy(&bundle_root).expect_err("malformed legacy JSON fails closed"); + assert!(!malformed.to_string().contains(&sensitive_root)); + assert!(malformed.to_string().contains("manifest.json")); +} + +#[cfg(unix)] +#[test] +fn reader_rejects_a_symlinked_bundle_root_without_following_it() { + use std::os::unix::fs::symlink; + + let temp = tempdir().expect("temporary root"); + let real_root = temp.path().join("real-private-root"); + let alias = temp.path().join("secret-root-alias"); + make_private_directory(&real_root); + symlink(&real_root, &alias).expect("bundle root symlink"); + + let error = read_sccm_manifest_or_legacy(&alias).expect_err("root symlink is rejected"); + assert!(!error.to_string().contains(&alias.display().to_string())); +} + +#[cfg(unix)] +#[test] +fn reader_opens_manifest_without_following_a_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("private-bundle"); + let outside = temp.path().join("outside-manifest.json"); + make_private_directory(&bundle_root); + fs::write(&outside, b"{}").expect("outside file"); + symlink(&outside, bundle_root.join(SCCM_MANIFEST_FILE_NAME)).expect("manifest symlink"); + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("reader must not follow the manifest symlink"); + assert!(!error + .to_string() + .contains(&bundle_root.display().to_string())); + assert!(!error.to_string().contains(&outside.display().to_string())); + assert!(error + .to_string() + .contains("manifest cannot be opened safely")); +} + +#[cfg(unix)] +#[test] +fn reader_rejects_a_nonprivate_bundle_directory() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("public-bundle"); + make_private_directory(&bundle_root); + fs::set_permissions(&bundle_root, fs::Permissions::from_mode(0o755)) + .expect("make bundle public"); + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("public bundle directory fails closed"); + assert!(error.to_string().contains("not private")); + assert!(!error + .to_string() + .contains(&bundle_root.display().to_string())); +} + +#[test] +fn manifest_wire_and_debug_never_gain_raw_host_or_native_path_fields() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("RealUser-secret-bundle"); + let current = physical_artifact(SccmRotation::Current, b"policy-current"); + write_native_bundle(&bundle_root, &[¤t], &[]); + + let manifest = read_sccm_manifest_or_legacy(&bundle_root).expect("validated manifest"); + let serialized = serde_json::to_string(&manifest).expect("manifest JSON"); + let debug = format!("{manifest:?}"); + let native_path = bundle_root.display().to_string(); + for public in [&serialized, &debug] { + assert!(!public.contains("LAB-CLIENT-SECRET")); + assert!(!public.contains(&native_path)); + assert!(!public.contains("RealUser")); + } + + let mut raw_host = native_manifest(vec![current.value], vec![]); + raw_host["host"] = json!("LAB-CLIENT-SECRET"); + assert!(serde_json::from_value::(raw_host).is_err()); +} + +#[test] +fn malformed_native_state_is_rejected_before_pure_projection() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let mut value = native_manifest( + vec![physical_artifact(SccmRotation::Current, b"policy-current").value], + vec![], + ); + value["artifacts"][0]["state"] = json!("absent"); + make_private_directory(&bundle_root); + fs::write( + bundle_root.join(SCCM_MANIFEST_FILE_NAME), + serde_json::to_vec(&value).expect("serialize malformed native manifest"), + ) + .expect("write malformed native manifest"); + + let error = read_sccm_client_intake_bundle(&bundle_root) + .expect_err("nonphysical state cannot claim a file"); + assert!(error.to_string().contains("nonphysical")); + let manifest: SccmBundleManifestV1 = serde_json::from_value(value).expect("wire shape"); + assert_ne!( + manifest.artifacts[0].state, + SccmManifestSourceState::Captured + ); +} + +#[test] +fn reader_enforces_the_physical_file_cap_per_canonical_source_before_evidence_reads() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let current = physical_artifact(SccmRotation::Current, b"policy-current"); + let rotated = physical_artifact(SccmRotation::Numbered(1), b"policy-rotation-one"); + write_native_bundle(&bundle_root, &[¤t, &rotated], &[]); + set_native_limits(&bundle_root, 1, 4096); + remove_written_evidence(&bundle_root, &rotated); + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("two rotations cannot bypass one-file source cap"); + assert!(error.to_string().contains("source file cap")); +} + +#[test] +fn reader_accepts_the_exact_physical_byte_cap_boundary() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let content = b"policy-current"; + let current = physical_artifact(SccmRotation::Current, content); + write_native_bundle(&bundle_root, &[¤t], &[]); + set_native_limits(&bundle_root, 1, content.len() as u64); + + read_sccm_manifest_or_legacy(&bundle_root) + .expect("an artifact exactly at the source byte cap remains valid"); +} + +#[test] +fn reader_rejects_multi_rotation_physical_bytes_over_the_source_cap() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let current = physical_artifact(SccmRotation::Current, b"policy-current"); + let rotated = physical_artifact(SccmRotation::Numbered(1), b"policy-rotation-one"); + write_native_bundle(&bundle_root, &[¤t, &rotated], &[]); + set_native_limits(&bundle_root, 2, b"policy-current".len() as u64); + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("rotations cannot collectively exceed their source byte cap"); + assert!(error.to_string().contains("source byte cap")); +} + +#[test] +fn reader_rejects_u64_max_physical_byte_metadata_before_evidence_reads() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let mut current = physical_artifact(SccmRotation::Current, b"policy-current"); + current.value["bytesCopied"] = json!(u64::MAX); + write_native_bundle(&bundle_root, &[¤t], &[]); + set_native_limits(&bundle_root, 1, u64::MAX); + remove_written_evidence(&bundle_root, ¤t); + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("extreme metadata fails before unbounded evidence verification"); + assert!(error.to_string().contains("physical artifact byte cap")); +} + +#[test] +fn reader_rejects_a_client_owned_per_artifact_ceiling_before_evidence_reads() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let mut current = physical_artifact(SccmRotation::Current, b"policy-current"); + current.value["bytesCopied"] = json!(256_u64 * 1024 * 1024 + 1); + write_native_bundle(&bundle_root, &[¤t], &[]); + set_native_limits(&bundle_root, 8, u64::MAX); + remove_written_evidence(&bundle_root, ¤t); + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("a manifest cannot raise the reader-owned artifact ceiling"); + assert!(error.to_string().contains("physical artifact byte cap")); +} + +#[test] +fn reader_rejects_a_client_owned_aggregate_ceiling_before_evidence_reads() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let mut artifacts = vec![ + physical_artifact(SccmRotation::Current, b"policy-current"), + physical_artifact(SccmRotation::LoUnderscore, b"policy-lo"), + physical_artifact(SccmRotation::Numbered(1), b"policy-one"), + physical_artifact(SccmRotation::Numbered(2), b"policy-two"), + physical_artifact(SccmRotation::Numbered(3), b"policy-three"), + ]; + for artifact in &mut artifacts { + artifact.value["bytesCopied"] = json!(205_u64 * 1024 * 1024); + } + let references = artifacts.iter().collect::>(); + write_native_bundle(&bundle_root, &references, &[]); + set_native_limits(&bundle_root, 8, u64::MAX); + for artifact in &artifacts { + remove_written_evidence(&bundle_root, artifact); + } + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("a manifest cannot raise the reader-owned aggregate ceiling"); + assert!(error.to_string().contains("aggregate physical byte cap")); +} + +#[cfg(unix)] +#[test] +fn reader_rejects_hard_linked_physical_evidence() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let current = physical_artifact(SccmRotation::Current, b"policy-current"); + write_native_bundle(&bundle_root, &[¤t], &[]); + let evidence = bundle_root.join(relative_path(&SccmRotation::Current)); + fs::hard_link(&evidence, bundle_root.join("duplicate-evidence-link")) + .expect("create a second name for the evidence inode"); + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("hard-linked physical evidence is not a private capture artifact"); + assert!(error.to_string().contains("cannot be opened safely")); +} diff --git a/src-tauri/tests/sccm_native_collection.rs b/src-tauri/tests/sccm_native_collection.rs new file mode 100644 index 000000000..3fcafbe66 --- /dev/null +++ b/src-tauri/tests/sccm_native_collection.rs @@ -0,0 +1,542 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use app_lib::sccm::collector::{ + capture_environment, discover_environment_with, PrivateSccmEnvironment, SccmCaptureRoot, + SccmDetectedRole, SccmDiscoveryBasis, SccmDiscoveryFailure, SccmDiscoveryIssue, + SccmDiscoveryIssueCode, SccmDiscoveryProvider, MAX_BYTES_PER_SOURCE, MAX_FRAGMENTS_PER_SOURCE, +}; +use app_lib::sccm::{ + read_sccm_client_intake_bundle, read_sccm_manifest_or_legacy, SccmCoverageState, SccmRole, +}; +use cmtraceopen_parser::sccm::server::windows::{ + normalize_server_bundle, SccmServerArtifactPayload, SccmServerIntakeAssessment, +}; + +#[derive(Clone)] +struct FakeProvider { + environment: PrivateSccmEnvironment, +} + +impl SccmDiscoveryProvider for FakeProvider { + fn discover(&self) -> Result { + Ok(self.environment.clone()) + } +} + +fn provider(role: SccmRole, roots: impl IntoIterator) -> FakeProvider { + FakeProvider { + environment: PrivateSccmEnvironment { + supported: true, + configmgr_version: Some("5.00.0001.0001".to_owned()), + roles: vec![SccmDetectedRole { + role: role.clone(), + basis: SccmDiscoveryBasis::Registry, + }], + roots: roots + .into_iter() + .map(|path| SccmCaptureRoot { + role: role.clone(), + path, + }) + .collect(), + private_host: Some("PRIVATE-HOST-SENTINEL".to_owned()), + private_site_code: Some("PRIVATE-SITE-SENTINEL".to_owned()), + ..PrivateSccmEnvironment::default() + }, + } +} + +fn capture( + provider: &FakeProvider, + parent: &Path, + name: &str, +) -> app_lib::sccm::collector::SccmCaptureResult { + capture_environment(provider, &parent.join(name)).expect("native capture") +} + +#[test] +fn public_result_excludes_private_discovery_sentinels() { + let logs = tempfile::tempdir().unwrap(); + let provider = provider(SccmRole::Client, [logs.path().to_owned()]); + let value = serde_json::to_string(&discover_environment_with(&provider).unwrap()).unwrap(); + assert!(!value.contains("PRIVATE-HOST-SENTINEL")); + assert!(!value.contains("PRIVATE-SITE-SENTINEL")); + assert!(!value.contains(logs.path().to_string_lossy().as_ref())); +} + +#[test] +fn discovery_roles_issues_and_order_are_deterministic() { + let root = tempfile::tempdir().unwrap(); + let mut provider = provider( + SccmRole::Provider, + [root.path().to_owned(), root.path().to_owned()], + ); + provider.environment.roles.extend([ + SccmDetectedRole { + role: SccmRole::Client, + basis: SccmDiscoveryBasis::Service, + }, + SccmDetectedRole { + role: SccmRole::Provider, + basis: SccmDiscoveryBasis::Cim, + }, + ]); + provider.environment.issues.extend([ + SccmDiscoveryIssue { + code: SccmDiscoveryIssueCode::CimAccessDenied, + role: Some(SccmRole::Provider), + }, + SccmDiscoveryIssue { + code: SccmDiscoveryIssueCode::RegistryAccessDenied, + role: Some(SccmRole::Client), + }, + ]); + let first = discover_environment_with(&provider).unwrap(); + let second = discover_environment_with(&provider).unwrap(); + assert_eq!(first, second); + assert_eq!(first.roles[0].role, SccmRole::Client); + assert_eq!(first.roles[1].basis, SccmDiscoveryBasis::Registry); + assert_eq!(first.roles[2].basis, SccmDiscoveryBasis::Cim); + assert_eq!(first.issues.len(), 2); +} + +#[test] +fn discovery_defaults_without_role_facts_never_claim_a_role() { + let logs = tempfile::tempdir().unwrap(); + let provider = FakeProvider { + environment: PrivateSccmEnvironment { + supported: true, + roots: vec![SccmCaptureRoot { + role: SccmRole::SiteServer, + path: logs.path().to_owned(), + }], + ..PrivateSccmEnvironment::default() + }, + }; + assert!(discover_environment_with(&provider) + .unwrap() + .roles + .is_empty()); +} + +#[test] +fn discovery_preserves_each_explicit_server_role_fact_without_inference() { + for role in [ + SccmRole::SiteServer, + SccmRole::ManagementPoint, + SccmRole::DistributionPoint, + SccmRole::SoftwareUpdatePoint, + SccmRole::WsUs, + SccmRole::Provider, + SccmRole::AdminService, + ] { + let discovery = discover_environment_with(&provider(role.clone(), [])).unwrap(); + assert_eq!( + discovery + .roles + .iter() + .map(|fact| fact.role.clone()) + .collect::>(), + vec![role] + ); + } +} + +#[test] +fn capture_rejects_zero_roles_without_creating_the_bundle() { + let bundles = tempfile::tempdir().unwrap(); + for supported in [true, false] { + let provider = FakeProvider { + environment: PrivateSccmEnvironment { + supported, + ..PrivateSccmEnvironment::default() + }, + }; + let bundle = bundles.path().join(if supported { + "supported-no-roles" + } else { + "unsupported-no-roles" + }); + let error = capture_environment(&provider, &bundle).unwrap_err(); + assert_eq!(error.code(), "noRolesDetected"); + assert!(!bundle.exists()); + } + assert_eq!(fs::read_dir(bundles.path()).unwrap().count(), 0); +} + +#[test] +fn capture_collects_all_supported_client_rotations_and_validates_manifest() { + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + for name in [ + "PolicyAgent.log", + "PolicyAgent.lo_", + "PolicyAgent.log.1", + "PolicyAgent.log.20260804-123456", + ] { + fs::write(logs.path().join(name), format!("content:{name}")).unwrap(); + } + let result = capture( + &provider(SccmRole::Client, [logs.path().to_owned()]), + bundles.path(), + "bundle", + ); + assert_eq!(result.artifact_count, 4); + assert_eq!( + result + .sources + .iter() + .filter(|source| source.state == SccmCoverageState::Captured) + .count(), + 4 + ); + assert!(result + .sources + .iter() + .any(|source| source.state == SccmCoverageState::Absent)); + assert!(bundles.path().join("bundle/sccm-manifest.json").is_file()); + let reopened = read_sccm_manifest_or_legacy(&bundles.path().join("bundle")).unwrap(); + assert!(reopened + .artifacts + .iter() + .any(|artifact| artifact.state == app_lib::sccm::SccmManifestSourceState::Absent)); +} + +#[test] +fn capture_reports_malformed_rotation_without_copying_it() { + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + fs::write(logs.path().join("PolicyAgent.log.latest"), b"unsafe suffix").unwrap(); + let result = capture( + &provider(SccmRole::Client, [logs.path().to_owned()]), + bundles.path(), + "bundle", + ); + assert!(result.sources.iter().any(|source| { + source.state == SccmCoverageState::Unsupported + && source.detail_code + == Some(app_lib::sccm::collector::SccmSourceDetailCode::MalformedRotation) + })); + assert_eq!(result.retained_bytes, 0); + let reopened = read_sccm_manifest_or_legacy(&bundles.path().join("bundle")).unwrap(); + assert!(reopened + .artifacts + .iter() + .any(|artifact| artifact.state == app_lib::sccm::SccmManifestSourceState::Unsupported)); +} + +#[test] +fn capture_persists_absent_and_read_failure_coverage() { + let parent = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + capture( + &provider( + SccmRole::Client, + [parent.path().join("missing-client-root")], + ), + bundles.path(), + "absent-bundle", + ); + let absent = read_sccm_manifest_or_legacy(&bundles.path().join("absent-bundle")).unwrap(); + assert!(absent + .artifacts + .iter() + .all(|artifact| artifact.state == app_lib::sccm::SccmManifestSourceState::Absent)); + + let not_a_directory = parent.path().join("not-a-directory"); + fs::write(¬_a_directory, b"not a root").unwrap(); + capture( + &provider(SccmRole::Client, [not_a_directory]), + bundles.path(), + "failed-bundle", + ); + let failed = read_sccm_manifest_or_legacy(&bundles.path().join("failed-bundle")).unwrap(); + assert!(failed.artifacts.iter().all(|artifact| { + artifact.state == app_lib::sccm::SccmManifestSourceState::FailedUnknownDetail + })); +} + +#[cfg(unix)] +#[test] +fn capture_persists_access_denied_coverage() { + use std::os::unix::fs::PermissionsExt; + + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + fs::set_permissions(logs.path(), fs::Permissions::from_mode(0o000)).unwrap(); + let result = capture( + &provider(SccmRole::Client, [logs.path().to_owned()]), + bundles.path(), + "bundle", + ); + fs::set_permissions(logs.path(), fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result + .sources + .iter() + .all(|source| source.state == SccmCoverageState::AccessDenied)); + let reopened = read_sccm_manifest_or_legacy(&bundles.path().join("bundle")).unwrap(); + assert!(reopened.artifacts.iter().all(|artifact| { + artifact.state == app_lib::sccm::SccmManifestSourceState::AccessDenied + })); +} + +#[test] +fn capture_applies_the_byte_cap_to_the_exact_retained_prefix() { + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + let file = fs::File::create(logs.path().join("PolicyAgent.log")).unwrap(); + file.set_len(MAX_BYTES_PER_SOURCE + 1).unwrap(); + let result = capture( + &provider(SccmRole::Client, [logs.path().to_owned()]), + bundles.path(), + "bundle", + ); + assert_eq!(result.retained_bytes, MAX_BYTES_PER_SOURCE); + assert!(result + .sources + .iter() + .any(|source| source.state == SccmCoverageState::Capped)); + let reopened = read_sccm_manifest_or_legacy(&bundles.path().join("bundle")).unwrap(); + assert!(reopened + .artifacts + .iter() + .any(|artifact| artifact.state == app_lib::sccm::SccmManifestSourceState::Capped)); +} + +#[test] +fn capture_applies_the_fragment_cap_per_source() { + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + fs::write(logs.path().join("PolicyAgent.log"), b"current").unwrap(); + fs::write(logs.path().join("PolicyAgent.lo_"), b"lo").unwrap(); + for number in 1..=8 { + fs::write( + logs.path().join(format!("PolicyAgent.log.{number}")), + b"rotation", + ) + .unwrap(); + } + let result = capture( + &provider(SccmRole::Client, [logs.path().to_owned()]), + bundles.path(), + "bundle", + ); + assert_eq!( + result + .sources + .iter() + .filter(|source| source.retained_bytes > 0) + .count(), + MAX_FRAGMENTS_PER_SOURCE + ); + assert!(result + .sources + .iter() + .any(|source| source.state == SccmCoverageState::Capped)); + let reopened = read_sccm_client_intake_bundle(&bundles.path().join("bundle")).unwrap(); + assert!(reopened + .capture_gaps + .iter() + .any(|gap| gap.coverage == SccmCoverageState::Capped)); +} + +#[test] +fn capture_keeps_same_basename_from_two_roots_distinct() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + fs::write(first.path().join("PolicyAgent.log"), b"first").unwrap(); + fs::write(second.path().join("PolicyAgent.log"), b"second").unwrap(); + let result = capture( + &provider( + SccmRole::Client, + [first.path().to_owned(), second.path().to_owned()], + ), + bundles.path(), + "bundle", + ); + assert_eq!(result.artifact_count, 2); + let evidence = bundles.path().join("bundle/evidence/sccm/client"); + assert_eq!( + walkdir_count_files(&evidence), + 2, + "root handles must prevent destination collisions" + ); +} + +#[test] +fn capture_rejects_a_preexisting_bundle_without_overwrite() { + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + fs::write(logs.path().join("PolicyAgent.log"), b"policy").unwrap(); + fs::create_dir(bundles.path().join("bundle")).unwrap(); + fs::write(bundles.path().join("bundle/sentinel"), b"keep").unwrap(); + let error = capture_environment( + &provider(SccmRole::Client, [logs.path().to_owned()]), + &bundles.path().join("bundle"), + ) + .unwrap_err(); + assert_eq!(error.code(), "destinationUnavailable"); + assert_eq!( + fs::read(bundles.path().join("bundle/sentinel")).unwrap(), + b"keep" + ); +} + +#[cfg(unix)] +#[test] +fn capture_rejects_symlink_evidence() { + use std::os::unix::fs::symlink; + + let logs = tempfile::tempdir().unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + symlink(outside.path(), logs.path().join("PolicyAgent.log")).unwrap(); + let result = capture( + &provider(SccmRole::Client, [logs.path().to_owned()]), + bundles.path(), + "bundle", + ); + assert_eq!(result.retained_bytes, 0); + assert!(result + .sources + .iter() + .any(|source| source.state == SccmCoverageState::Skipped)); + let reopened = read_sccm_manifest_or_legacy(&bundles.path().join("bundle")).unwrap(); + assert!(reopened + .artifacts + .iter() + .any(|artifact| artifact.state == app_lib::sccm::SccmManifestSourceState::Skipped)); +} + +#[test] +fn capture_writes_and_parser_validates_the_server_manifest() { + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + fs::write(logs.path().join("sitecomp.log"), b"server evidence").unwrap(); + let result = capture( + &provider(SccmRole::SiteServer, [logs.path().to_owned()]), + bundles.path(), + "bundle", + ); + assert_eq!(result.artifact_count, 1); + let manifest = + fs::read_to_string(bundles.path().join("bundle/sccm-server-manifest.json")).unwrap(); + assert!(!manifest.contains("PRIVATE-HOST-SENTINEL")); + assert!(!manifest.contains("PRIVATE-SITE-SENTINEL")); + assert!(!manifest.contains(logs.path().to_string_lossy().as_ref())); + let manifest_value: serde_json::Value = serde_json::from_str(&manifest).unwrap(); + assert!(manifest_value["artifacts"] + .as_array() + .unwrap() + .iter() + .any(|artifact| artifact["captureState"] == "absent")); +} + +#[test] +fn capture_parser_validates_each_declared_server_role_manifest() { + for (role, basename) in [ + (SccmRole::ManagementPoint, "MP_GetAuth.log"), + (SccmRole::DistributionPoint, "SMSDPProv.log"), + (SccmRole::SoftwareUpdatePoint, "WSUSCtrl.log"), + (SccmRole::Provider, "Smsprov.log"), + (SccmRole::AdminService, "AdminService.log"), + ] { + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + fs::write(logs.path().join(basename), b"server evidence").unwrap(); + let result = capture( + &provider(role.clone(), [logs.path().to_owned()]), + bundles.path(), + "bundle", + ); + assert_eq!(result.roles, vec![role]); + assert!(bundles + .path() + .join("bundle/sccm-server-manifest.json") + .is_file()); + } + + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + fs::write(logs.path().join("WsusHealth.json"), b"{}").unwrap(); + let mut wsus = provider(SccmRole::WsUs, [logs.path().to_owned()]); + wsus.environment.roles.push(SccmDetectedRole { + role: SccmRole::SoftwareUpdatePoint, + basis: SccmDiscoveryBasis::Registry, + }); + capture(&wsus, bundles.path(), "bundle"); +} + +#[test] +fn capture_server_file_cap_reopens_as_capped_with_limits() { + let logs = tempfile::tempdir().unwrap(); + let bundles = tempfile::tempdir().unwrap(); + fs::write(logs.path().join("Smsprov.log"), b"current").unwrap(); + fs::write(logs.path().join("Smsprov.lo_"), b"lo").unwrap(); + for number in 1..=8 { + fs::write( + logs.path().join(format!("Smsprov.log.{number}")), + b"rotation", + ) + .unwrap(); + } + let result = capture( + &provider(SccmRole::Provider, [logs.path().to_owned()]), + bundles.path(), + "bundle", + ); + assert!(result + .sources + .iter() + .any(|source| source.state == SccmCoverageState::Capped)); + + let assessment = reopen_server_bundle(&bundles.path().join("bundle")); + let capped = assessment + .artifacts + .iter() + .find(|artifact| artifact.state == SccmCoverageState::Capped) + .expect("omitted server rotation remains capped"); + let limit = capped + .collection_limit + .as_ref() + .expect("capped coverage retains its collection limits"); + assert_eq!(limit.byte_limit, MAX_BYTES_PER_SOURCE); + assert_eq!(limit.file_limit, Some(MAX_FRAGMENTS_PER_SOURCE as u64)); + assert!(limit.limit_applied); +} + +fn reopen_server_bundle(bundle_root: &Path) -> SccmServerIntakeAssessment { + let manifest = fs::read_to_string(bundle_root.join("sccm-server-manifest.json")).unwrap(); + let value: serde_json::Value = serde_json::from_str(&manifest).unwrap(); + let payloads = value["artifacts"] + .as_array() + .unwrap() + .iter() + .filter_map(|artifact| { + let relative = artifact["relativePath"].as_str()?; + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact["artifactId"].as_str().unwrap().to_owned(), + bytes: fs::read(bundle_root.join(relative)).unwrap(), + }) + }) + .collect::>(); + normalize_server_bundle(&manifest, &payloads).expect("reopened server bundle") +} + +fn walkdir_count_files(root: &Path) -> usize { + let mut count = 0; + let mut pending = vec![root.to_owned()]; + while let Some(path) = pending.pop() { + for entry in fs::read_dir(path).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + pending.push(entry.path()); + } else { + count += 1; + } + } + } + count +} diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index 04922e755..5611547fe 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { invoke } from "@tauri-apps/api/core"; import { + captureSccmDiagnostics, + discoverSccmEnvironment, getSafeErrorMessage, graphGetAuthStatus, graphRequestMissingPermissions, openLogFile, + revealInFileManager, } from "./commands"; import { readAccessDenied } from "./source-error"; @@ -71,6 +74,42 @@ beforeEach(() => { vi.mocked(invoke).mockReset(); }); +describe("SCCM product-path IPC boundary", () => { + it("invokes discovery and capture without accepting frontend inputs", async () => { + const discovery = { supported: true, roles: [], sources: [], issues: [] }; + const capture = { + bundleRoot: "C:\\capture", + capturedAtUtc: "2026-08-04T14:30:00Z", + roles: [], + sources: [], + artifactCount: 0, + retainedBytes: 0, + }; + vi.mocked(invoke) + .mockResolvedValueOnce(discovery) + .mockResolvedValueOnce(capture) + .mockResolvedValueOnce(undefined); + + await expect(discoverSccmEnvironment()).resolves.toBe(discovery); + await expect(captureSccmDiagnostics()).resolves.toBe(capture); + await expect(revealInFileManager(capture.bundleRoot)).resolves.toBeUndefined(); + + expect(invoke).toHaveBeenNthCalledWith( + 1, + "discover_sccm_environment", + undefined, + ); + expect(invoke).toHaveBeenNthCalledWith( + 2, + "capture_sccm_diagnostics", + undefined, + ); + expect(invoke).toHaveBeenNthCalledWith(3, "reveal_in_file_manager", { + path: capture.bundleRoot, + }); + }); +}); + describe("Graph permission upgrade IPC boundary", () => { it("invokes the zero-argument native permission upgrade command", async () => { const result = { diff --git a/src/lib/commands.ts b/src/lib/commands.ts index f47025886..a6f89ff89 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -42,6 +42,10 @@ import type { EspRelaunchResult, EspSessionEnvelope, } from "../workspaces/esp-diagnostics/types"; +import type { + SccmCaptureResult, + SccmEnvironmentDiscovery, +} from "../workspaces/sccm/types"; export interface FileAssociationPromptStatus { supported: boolean; @@ -511,6 +515,18 @@ export async function getAvailableWorkspaces(): Promise { return invokeCommand("get_available_workspaces"); } +export async function discoverSccmEnvironment(): Promise { + return invokeCommand("discover_sccm_environment"); +} + +export async function captureSccmDiagnostics(): Promise { + return invokeCommand("capture_sccm_diagnostics"); +} + +export async function revealInFileManager(path: string): Promise { + return invokeCommand("reveal_in_file_manager", { path }); +} + export async function getUpdatePolicy(): Promise { return invokeCommand("get_update_policy"); } diff --git a/src/types/log.ts b/src/types/log.ts index 550daadda..4f15cfd22 100644 --- a/src/types/log.ts +++ b/src/types/log.ts @@ -67,6 +67,7 @@ export type WorkspaceId = | "deployment" | "event-log" | "esp-diagnostics" + | "sccm" | "secureboot" | "sysmon" | "timeline" diff --git a/src/workspaces/registry.test.ts b/src/workspaces/registry.test.ts index db08ca9d0..1e110382e 100644 --- a/src/workspaces/registry.test.ts +++ b/src/workspaces/registry.test.ts @@ -43,6 +43,7 @@ import { } from "./esp-diagnostics"; import { eventLogWorkspace } from "./event-log"; import { logWorkspace } from "./log"; +import { sccmWorkspace } from "./sccm"; import { getAvailableWorkspaces, getWorkspace } from "./registry"; import type { WorkspaceDefinition } from "./types"; @@ -592,6 +593,30 @@ describe("ESP workspace registration", () => { }); }); +describe("SCCM workspace registration", () => { + it("registers a Windows-only live-acquisition workspace without shell sidebars", () => { + expect(getWorkspace("sccm")).toBe(sccmWorkspace); + expect(sccmWorkspace.label).toBe("SCCM Diagnostics"); + expect(sccmWorkspace.platforms).toEqual(["windows"]); + expect(sccmWorkspace.capabilities).toMatchObject({ + sidebar: false, + liveAcquisition: true, + tabStrip: false, + knownSources: false, + }); + expect( + getAvailableWorkspaces("windows").map((workspace) => workspace.id), + ).toContain("sccm"); + expect( + getAvailableWorkspaces("macos").map((workspace) => workspace.id), + ).not.toContain("sccm"); + expect( + getAvailableWorkspaces("linux").map((workspace) => workspace.id), + ).not.toContain("sccm"); + expect(shouldRenderWorkspaceSidebar(sccmWorkspace)).toBe(false); + }); +}); + describe("ESP workspace app chrome", () => { it("mounts one global session listener and never stops collection on navigation", async () => { useEspDiagnosticsStore.setState({ diff --git a/src/workspaces/registry.ts b/src/workspaces/registry.ts index ab3fa3d23..65d445a22 100644 --- a/src/workspaces/registry.ts +++ b/src/workspaces/registry.ts @@ -14,6 +14,7 @@ import { sysmonWorkspace } from "./sysmon"; import { securebootWorkspace } from "./secureboot"; import { timelineWorkspace } from "./timeline"; import { dnsDhcpWorkspace } from "./dns-dhcp"; +import { sccmWorkspace } from "./sccm"; const ALL_WORKSPACES: WorkspaceDefinition[] = [ logWorkspace, @@ -25,6 +26,7 @@ const ALL_WORKSPACES: WorkspaceDefinition[] = [ deploymentWorkspace, eventLogWorkspace, espDiagnosticsWorkspace, + sccmWorkspace, sysmonWorkspace, securebootWorkspace, timelineWorkspace, diff --git a/src/workspaces/sccm/SccmWorkspace.test.tsx b/src/workspaces/sccm/SccmWorkspace.test.tsx new file mode 100644 index 000000000..55f91ac99 --- /dev/null +++ b/src/workspaces/sccm/SccmWorkspace.test.tsx @@ -0,0 +1,251 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + captureSccmDiagnostics, + discoverSccmEnvironment, + revealInFileManager, +} from "../../lib/commands"; +import { SccmWorkspace } from "./SccmWorkspace"; +import { useSccmStore } from "./sccm-store"; +import type { + SccmCaptureResult, + SccmEnvironmentDiscovery, + SccmSourceStatus, +} from "./types"; + +vi.mock("../../lib/commands", () => ({ + captureSccmDiagnostics: vi.fn(), + discoverSccmEnvironment: vi.fn(), + revealInFileManager: vi.fn(), +})); + +const COVERAGE_ROWS: SccmSourceStatus[] = [ + { + role: "client", + sourceId: "client-policy-current", + rotation: "current", + state: "captured", + retainedBytes: 2048, + }, + { + role: "client", + sourceId: "client-policy-absent", + rotation: "current", + state: "absent", + retainedBytes: 0, + }, + { + role: "client", + sourceId: "client-policy-denied", + rotation: "loUnderscore", + state: "accessDenied", + retainedBytes: 0, + detailCode: "accessDenied", + }, + { + role: "managementPoint", + sourceId: "mp-capped", + rotation: "numbered", + state: "capped", + retainedBytes: 16_777_216, + detailCode: "byteLimitExceeded", + }, + { + role: "distributionPoint", + sourceId: "dp-skipped", + rotation: "timestamped", + state: "skipped", + retainedBytes: 0, + }, + { + role: "softwareUpdatePoint", + sourceId: "sup-unsupported", + rotation: "unknown", + state: "unsupported", + retainedBytes: 0, + detailCode: "unsupportedPlatform", + }, + { + role: "siteServer", + sourceId: "site-malformed", + rotation: "unknown", + state: "parseFailed", + retainedBytes: 0, + detailCode: "malformedRotation", + }, +]; + +const DISCOVERY: SccmEnvironmentDiscovery = { + supported: true, + configmgrVersion: "5.00.9128.1000", + roles: [ + { role: "client", basis: "service" }, + { role: "managementPoint", basis: "cim" }, + ], + sources: COVERAGE_ROWS, + issues: [{ code: "registryAccessDenied", role: "client" }], +}; + +const ROLELESS_DISCOVERY: SccmEnvironmentDiscovery = { + supported: true, + configmgrVersion: null, + roles: [], + sources: [], + issues: [], +}; + +const CAPTURE: SccmCaptureResult = { + bundleRoot: "C:\\Users\\TEST\\AppData\\Local\\cmtrace-open\\sccm\\bundle-id", + capturedAtUtc: "2026-08-04T14:30:00Z", + roles: ["client", "managementPoint"], + sources: COVERAGE_ROWS, + artifactCount: 4, + retainedBytes: 16_779_264, +}; + +afterEach(cleanup); + +beforeEach(() => { + vi.clearAllMocks(); + useSccmStore.getState().reset(); +}); + +describe("SccmWorkspace", () => { + it("starts with a read-only environment discovery action", () => { + render(); + + expect(screen.getByText("Read-only environment discovery")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Discover SCCM environment" }), + ).toBeEnabled(); + expect( + screen.queryByRole("button", { name: "Capture diagnostic bundle" }), + ).not.toBeInTheDocument(); + }); + + it("renders discovered roles and every explicit source coverage state", async () => { + vi.mocked(discoverSccmEnvironment).mockResolvedValue(DISCOVERY); + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Discover SCCM environment" }), + ); + + expect(await screen.findAllByText("Management Point")).toHaveLength(2); + for (const label of [ + "Captured", + "Absent", + "Access denied", + "Capped", + "Skipped", + "Unsupported", + "Parse failed", + ]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + expect(screen.getByText("Registry access denied")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Capture diagnostic bundle" }), + ).toBeEnabled(); + }); + + it("keeps capture unavailable when discovery finds no SCCM roles", async () => { + vi.mocked(discoverSccmEnvironment).mockResolvedValue(ROLELESS_DISCOVERY); + vi.mocked(captureSccmDiagnostics).mockResolvedValue(CAPTURE); + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Discover SCCM environment" }), + ); + + expect(await screen.findByText("No SCCM roles detected")).toBeInTheDocument(); + expect( + screen.getByText( + "This host is supported, but discovery found no Configuration Manager roles to collect.", + ), + ).toBeInTheDocument(); + const capture = screen.getByRole("button", { + name: "Capture diagnostic bundle", + }); + expect(capture).toBeDisabled(); + + fireEvent.click(capture); + + expect(captureSccmDiagnostics).not.toHaveBeenCalled(); + expect(screen.queryByText("Bundle retained")).not.toBeInTheDocument(); + }); + + it("disables capture while native work is in flight", async () => { + let finishCapture: ((result: SccmCaptureResult) => void) | undefined; + vi.mocked(discoverSccmEnvironment).mockResolvedValue(DISCOVERY); + vi.mocked(captureSccmDiagnostics).mockImplementation( + () => + new Promise((resolve) => { + finishCapture = resolve; + }), + ); + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Discover SCCM environment" }), + ); + const capture = await screen.findByRole("button", { + name: "Capture diagnostic bundle", + }); + fireEvent.click(capture); + + expect( + screen.getByRole("button", { name: "Capturing diagnostic bundle" }), + ).toBeDisabled(); + + finishCapture?.(CAPTURE); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Capture diagnostic bundle" }), + ).toBeEnabled(), + ); + }); + + it("keeps the previous discovery visible when capture fails", async () => { + vi.mocked(discoverSccmEnvironment).mockResolvedValue(DISCOVERY); + vi.mocked(captureSccmDiagnostics).mockRejectedValue( + new Error("Capture destination is unavailable."), + ); + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Discover SCCM environment" }), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Capture diagnostic bundle" }), + ); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Capture destination is unavailable.", + ); + expect(screen.getByText("client-policy-current")).toBeInTheDocument(); + expect(screen.getAllByText("Management Point")).toHaveLength(2); + }); + + it("reports retained counts and reveals a successful capture", async () => { + vi.mocked(discoverSccmEnvironment).mockResolvedValue(DISCOVERY); + vi.mocked(captureSccmDiagnostics).mockResolvedValue(CAPTURE); + vi.mocked(revealInFileManager).mockResolvedValue(undefined); + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Discover SCCM environment" }), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Capture diagnostic bundle" }), + ); + + expect(await screen.findByText("4 artifacts")).toBeInTheDocument(); + expect(screen.getByText("16.0 MiB retained")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reveal bundle" })); + await waitFor(() => + expect(revealInFileManager).toHaveBeenCalledWith(CAPTURE.bundleRoot), + ); + }); +}); diff --git a/src/workspaces/sccm/SccmWorkspace.tsx b/src/workspaces/sccm/SccmWorkspace.tsx new file mode 100644 index 000000000..3340f5f8f --- /dev/null +++ b/src/workspaces/sccm/SccmWorkspace.tsx @@ -0,0 +1,405 @@ +import type { ReactNode } from "react"; +import { Button, Spinner } from "@fluentui/react-components"; +import { + ArchiveRegular, + CheckmarkCircleRegular, + DatabaseSearchRegular, + DismissCircleRegular, + FolderOpenRegular, + InfoRegular, + WarningRegular, +} from "@fluentui/react-icons"; +import { + captureSccmDiagnostics, + discoverSccmEnvironment, + revealInFileManager, +} from "../../lib/commands"; +import { useSccmStore } from "./sccm-store"; +import type { + SccmCoverageState, + SccmDiscoveryBasis, + SccmDiscoveryIssueCode, + SccmRole, + SccmRotationCategory, + SccmSourceDetailCode, + SccmSourceStatus, +} from "./types"; +import "./sccm-workspace.css"; + +const ROLE_LABELS: Record = { + client: "Client", + siteServer: "Site Server", + managementPoint: "Management Point", + distributionPoint: "Distribution Point", + softwareUpdatePoint: "Software Update Point", + wsUs: "WSUS", + provider: "Provider", + adminService: "Admin Service", +}; + +const BASIS_LABELS: Record = { + registry: "Registry", + service: "Service", + cim: "CIM", +}; + +const ROTATION_LABELS: Record = { + current: "Current", + loUnderscore: "LO_", + numbered: "Numbered", + timestamped: "Timestamped", + unknown: "Unknown", +}; + +const COVERAGE_LABELS: Record = { + captured: "Captured", + absent: "Absent", + accessDenied: "Access denied", + capped: "Capped", + skipped: "Skipped", + unsupported: "Unsupported", + parseFailed: "Parse failed", +}; + +const DETAIL_LABELS: Record = { + accessDenied: "Access denied", + byteLimitExceeded: "Byte limit exceeded", + fileLimitExceeded: "File limit exceeded", + malformedRotation: "Malformed rotation", + readFailed: "Read failed", + unsafePath: "Unsafe path rejected", + unsupportedPlatform: "Unsupported platform", +}; + +const ISSUE_LABELS: Record = { + unsupportedPlatform: "Unsupported platform", + registryAccessDenied: "Registry access denied", + cimAccessDenied: "CIM access denied", + discoveryFailed: "Discovery failed", +}; + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message.trim() + ? error.message + : fallback; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function formatCaptureTime(value: string): string { + const timestamp = new Date(value); + return Number.isNaN(timestamp.getTime()) + ? value + : timestamp.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }); +} + +function coverageIcon(state: SccmCoverageState): ReactNode { + switch (state) { + case "captured": + return